Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8073014bce
|
||
|
|
f8cfbf05c8
|
||
|
|
b4234e0387
|
||
|
|
d55006dd0a
|
||
|
|
745af2f1b9
|
||
|
|
7e9ce436d3
|
||
|
|
7e3054f6fa
|
||
|
|
98546adf41
|
||
|
|
daeff63d2a
|
||
|
|
80aa00670c
|
||
|
|
aa4f1d15e7
|
||
|
|
5af0200008
|
||
|
|
1d9f6f7e13
|
||
|
|
0f64fd5ab1
|
||
|
|
e555c4c116
|
||
|
|
91d530e29b
|
||
|
|
34f2561b50
|
||
|
|
fd29883d84
|
||
|
|
e083fdcafe
|
||
|
|
2fa7d8963a
|
||
|
|
e4edf10440
|
||
|
|
d5c2d0e343
|
||
|
|
5cd559b883
|
||
|
|
0c9a6a1a1b
|
||
|
|
3ee970a54b
|
||
|
|
e12375f2d2
|
||
|
|
8d7d31a4db
|
||
|
|
ed21281fa3
|
||
|
|
13350e5cea
|
||
|
|
39b7ba27fb
|
||
|
|
ee346ee9d8
|
||
|
|
461c8c448f
|
||
|
|
bff65230bb
|
||
|
|
0afb56abe0
|
||
|
|
a02b7b57f7
|
||
|
|
39c1c76db1
|
||
|
|
9b6d1ccd3c
|
||
|
|
a2d7f0d9d6
|
||
|
|
f8cbcd4368
|
||
|
|
04f1414118
|
||
|
|
dad223d4ad
|
||
|
|
e5ae1ed4ba
|
||
|
|
cb892a36a4
|
||
|
|
722eb0754d
|
||
|
|
1ae7bbdee3
|
||
|
|
6b2a5ed472
|
||
|
|
29689ae4ec
|
||
|
|
9c10183ab6
|
||
|
|
f1e8f44f42
|
||
|
|
7073330916
|
||
|
|
f10818964c
|
||
|
|
7fd8e5d92d
|
||
|
|
2adafb51f2
|
||
|
|
22c8f9d793
|
||
|
|
c0bd997287
|
||
|
|
85d4a0588a
|
||
|
|
fd9911855a
|
||
|
|
cd3088a6db
|
||
|
|
a2ab1ad261
|
||
|
|
5b8f2422ab
|
||
|
|
477e5ba8b2
|
||
|
|
8b1820823b
|
||
|
|
c429215626
|
||
|
|
9b7a6edd14
|
||
|
|
bbab33f55f
|
||
|
|
432ed37f45
|
||
|
|
a56742b421
|
||
|
|
9fd66f6668
|
||
|
|
51caae3b7c
|
||
|
|
80a54eed06
|
||
|
|
f626c5aa33
|
||
|
|
5df0bbc551
|
||
|
|
e61d56b59a
|
||
|
|
0bc5ca0315
|
||
|
|
2a56834ff3
|
||
|
|
cddf197a17
|
||
|
|
904e657bb9
|
||
|
|
e652850dfa
|
||
|
|
bfe20a84dd
|
||
|
|
46a8a62110
|
||
|
|
de582ae157
|
||
|
|
eb64de6ea3
|
||
|
|
eab6d10a19
|
||
|
|
76f6bd1a1e
|
||
|
|
29a9a1d07f
|
||
|
|
4d37b4f570
|
||
|
|
9d41e6ceeb
|
||
|
|
6d10a569d9
|
||
|
|
5f63718d62
|
||
|
|
eaffab1f27
|
||
|
|
d01c01a216
|
||
|
|
0b6bc5d0a0
|
||
|
|
3f5983685f
|
||
|
|
41d89e0993
|
||
|
|
a424dea5ca
|
||
|
|
ab7ae99807
|
||
|
|
5d47c511be
|
||
|
|
7713ba5bad | ||
|
|
ce0b28de4f
|
@@ -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: # Replace with a single Buy Me a Coffee username
|
||||
buy_me_a_coffee: treeman183
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
@@ -10,17 +10,26 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
WEB_EXT_IGNORE_FILES: scripts/**
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install deps
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install project dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Install web-ext
|
||||
run: npm install -g web-ext
|
||||
|
||||
- name: Lint
|
||||
run: web-ext lint
|
||||
run: web-ext lint --source-dir extension
|
||||
|
||||
# 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
|
||||
@@ -30,7 +39,7 @@ jobs:
|
||||
web-ext sign \
|
||||
--api-key ${{ secrets.FIREFOX_API_KEY }} \
|
||||
--api-secret ${{ secrets.FIREFOX_API_SECRET }} \
|
||||
--source-dir . \
|
||||
--source-dir extension \
|
||||
--artifacts-dir web-ext-artifacts \
|
||||
--channel unlisted
|
||||
|
||||
@@ -70,6 +79,6 @@ jobs:
|
||||
web-ext sign \
|
||||
--api-key ${{ secrets.FIREFOX_API_KEY }} \
|
||||
--api-secret ${{ secrets.FIREFOX_API_SECRET }} \
|
||||
--source-dir . \
|
||||
--source-dir extension \
|
||||
--artifacts-dir web-ext-artifacts \
|
||||
--channel listed
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"kiroAgent.configureMCP": "Disabled"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Implementation Plan
|
||||
|
||||
Constraints: local commits only; no push; no browser testing; one commit per feature.
|
||||
|
||||
## Controller and targeting
|
||||
|
||||
- [x] Keep the controller visible for its own video in element and ancestor fullscreen.
|
||||
- [x] Target popup actions at the frame represented by the displayed speed; keep “all videos” intentional.
|
||||
- [x] Ignore shortcuts originating from editable controls, including shadow-DOM inputs.
|
||||
|
||||
## Accessibility and usability
|
||||
|
||||
- [x] Give in-player controls accessible names, keyboard behavior, and visible focus.
|
||||
- [x] Make control-bar customization operable by keyboard as well as drag and drop.
|
||||
- [x] Label generated shortcut and site-rule form controls.
|
||||
- [x] Improve popup status announcements, focus indicators, and icon-search semantics.
|
||||
|
||||
## Settings safety and validation
|
||||
|
||||
- [x] Confirm before Restore Defaults removes preferences and remembered data.
|
||||
- [x] Report partial imports accurately when custom icons cannot be restored.
|
||||
- [x] Reject malformed slash-prefixed regular expressions before saving site rules.
|
||||
|
||||
## Extension lifecycle and copy
|
||||
|
||||
- [x] Initialize and synchronize the disabled toolbar icon from background state.
|
||||
- [x] Correct shortcut, subtitle-nudge, live-update, and obsolete troubleshooting copy.
|
||||
- [x] Run automated tests in the release workflow before packaging.
|
||||
|
||||
## Verification
|
||||
|
||||
- [x] Run focused automated checks after each non-trivial change.
|
||||
- [x] Run the complete non-browser test suite and review the final local commit series.
|
||||
- [x] Leave cross-site fullscreen visual verification for reporter/user validation.
|
||||
@@ -1,8 +1,8 @@
|
||||
# Available for Firefox
|
||||
# Available for Firefox
|
||||
|
||||
[](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,9 +33,11 @@ last point to listen to it a few more times.
|
||||
|
||||

|
||||
|
||||
#### *Install [Chrome](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk) or [Firefox](https://addons.mozilla.org/en-us/firefox/addon/videospeed/) Extension*
|
||||
## Using the extension
|
||||
|
||||
\*\* Once the extension is installed simply navigate to any page that offers
|
||||
[](https://addons.mozilla.org/firefox/addon/speeder/)
|
||||
|
||||
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,
|
||||
@@ -54,32 +56,42 @@ settings page, as well as add additional shortcut keys to match your
|
||||
preferences. For example, you can assign multiple different "preferred speed"
|
||||
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".
|
||||
and choose an action from "Add shortcut…".
|
||||
|
||||

|
||||
<img width="1760" height="1330" alt="image" src="https://github.com/user-attachments/assets/32e814dd-93ea-4943-8ec9-3eca735447ac" />
|
||||
|
||||
Some sites may assign other functionality to one of the assigned shortcut keys —
|
||||
these collisions are inevitable, unfortunately. As a workaround, the extension
|
||||
listens both for lower and upper case values (i.e. you can use
|
||||
`Shift-<shortcut>`) if there is other functionality assigned to the lowercase
|
||||
key. This is not a perfect solution, as some sites may listen to both, but works
|
||||
most of the time.
|
||||
Some sites may assign other functionality to one of the assigned shortcut keys.
|
||||
You can record `Shift+<shortcut>` as a separate, exact binding, or use a site
|
||||
rule to block the site from capturing a particular Speeder shortcut. Shift is
|
||||
not applied automatically to an unshifted binding.
|
||||
|
||||
### FAQ
|
||||
## Development
|
||||
|
||||
**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.
|
||||
The unpacked extension root is `extension/`. Load that directory in
|
||||
`about:debugging`, and run extension tooling against it, for example:
|
||||
|
||||
**What is this fork all about?** This is a fork of
|
||||
[CodeBicycle's Video Speed Controller extension for Firefox](https://github.com/codebicycle/videospeed)
|
||||
```sh
|
||||
npm test
|
||||
npx --yes web-ext lint --source-dir extension
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### The video controls are not showing up?
|
||||
|
||||
Speeder works with HTML5 video and, when enabled in settings, HTML5 audio. Check
|
||||
that Speeder is enabled for the current site, then use the popup's "Rescan page
|
||||
for videos" action after a player loads dynamically. Browser-internal pages and
|
||||
players that do not expose HTML5 media to extensions cannot be controlled.
|
||||
|
||||
### 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 +0,0 @@
|
||||
chrome.runtime.onMessage.addListener(function (request) {
|
||||
if (request.action === "openOptions") {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL("options.html") });
|
||||
}
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
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()
|
||||
|
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 |
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,67 @@
|
||||
function setToolbarIcon(enabled) {
|
||||
var suffix = enabled === false ? "_disabled" : "";
|
||||
chrome.browserAction.setIcon({
|
||||
path: {
|
||||
19: "assets/icons/icon19" + suffix + ".png",
|
||||
38: "assets/icons/icon38" + suffix + ".png",
|
||||
48: "assets/icons/icon48" + suffix + ".png"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var pausedTabIds = new Set();
|
||||
|
||||
function getMessageTabId(request, sender) {
|
||||
if (request && Number.isInteger(request.tabId)) return request.tabId;
|
||||
return sender && sender.tab && Number.isInteger(sender.tab.id)
|
||||
? sender.tab.id
|
||||
: null;
|
||||
}
|
||||
|
||||
chrome.storage.sync.get(["enabled"], function(storage) {
|
||||
if (!chrome.runtime.lastError) setToolbarIcon(storage.enabled !== false);
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener(function(changes, areaName) {
|
||||
if (areaName === "sync" && changes.enabled) {
|
||||
setToolbarIcon(changes.enabled.newValue !== false);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
|
||||
if (request.action === "openOptions") {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") });
|
||||
return false;
|
||||
}
|
||||
if (request.action === "get_tab_pause_state") {
|
||||
var queriedTabId = getMessageTabId(request, sender);
|
||||
sendResponse({
|
||||
paused: queriedTabId !== null && pausedTabIds.has(queriedTabId)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (request.action === "set_tab_paused") {
|
||||
var tabId = getMessageTabId(request, sender);
|
||||
if (tabId === null) {
|
||||
sendResponse({ paused: false });
|
||||
return false;
|
||||
}
|
||||
var paused = request.paused === true;
|
||||
if (paused) pausedTabIds.add(tabId);
|
||||
else pausedTabIds.delete(tabId);
|
||||
chrome.tabs.sendMessage(
|
||||
tabId,
|
||||
{ action: "set_tab_paused", paused: paused },
|
||||
function () {
|
||||
void chrome.runtime.lastError;
|
||||
}
|
||||
);
|
||||
sendResponse({ paused: paused });
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener(function(tabId) {
|
||||
pausedTabIds.delete(tabId);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Runs via chrome.tabs.executeScript(allFrames) in the same isolated world as inject.js */
|
||||
(function () {
|
||||
try {
|
||||
if (typeof getPrimaryVideoElement !== "function") {
|
||||
return null;
|
||||
}
|
||||
var v = getPrimaryVideoElement();
|
||||
if (!v) return null;
|
||||
return {
|
||||
speed: v.playbackRate,
|
||||
frameToken:
|
||||
typeof tc === "object" && typeof tc.frameToken === "string"
|
||||
? tc.frameToken
|
||||
: null,
|
||||
diagnostics:
|
||||
typeof getDiagnosticsSnapshot === "function"
|
||||
? getDiagnosticsSnapshot(v)
|
||||
: null,
|
||||
preferred: !v.paused,
|
||||
forceLastSavedSpeed: Boolean(
|
||||
typeof tc === "object" && tc.settings && tc.settings.forceLastSavedSpeed
|
||||
),
|
||||
forceLastSavedSpeedControlledBySiteRule: Boolean(
|
||||
typeof tc === "object" &&
|
||||
tc.activeSiteRule &&
|
||||
tc.activeSiteRule.forceLastSavedSpeed !== undefined
|
||||
)
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
@@ -6,14 +6,28 @@
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
pointer-events: none !important;
|
||||
z-index: 2147483646 !important;
|
||||
/* Keep the interactive controller above player-owned click/pause panes. */
|
||||
z-index: 2147483647 !important;
|
||||
white-space: normal;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Use minimal z-index for non-YouTube sites to avoid overlapping modals */
|
||||
.vsc-controller.vsc-non-youtube {
|
||||
z-index: 1 !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;
|
||||
}
|
||||
|
||||
.vsc-nosource {
|
||||
@@ -0,0 +1,5363 @@
|
||||
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;
|
||||
}
|
||||
|
||||
function getDiagnosticsSnapshot(media) {
|
||||
var primary = media || getPrimaryVideoElement();
|
||||
if (!primary) return null;
|
||||
|
||||
var controller = primary.vsc || null;
|
||||
var wrapper = controller && controller.div;
|
||||
var fullscreenElement = getFullscreenElement(primary.ownerDocument);
|
||||
var rect = null;
|
||||
try {
|
||||
rect = primary.getBoundingClientRect();
|
||||
} catch (_error) {}
|
||||
|
||||
return {
|
||||
mediaCount: tc.mediaElements.filter(function(item) {
|
||||
return item && item.isConnected;
|
||||
}).length,
|
||||
mediaType: String(primary.nodeName || "media").toLowerCase(),
|
||||
playbackRate: Number(primary.playbackRate),
|
||||
paused: primary.paused === true,
|
||||
ended: primary.ended === true,
|
||||
readyState: Number(primary.readyState) || 0,
|
||||
muted: primary.muted === true,
|
||||
volume: Number(primary.volume),
|
||||
dimensions: rect
|
||||
? [Math.round(Number(rect.width) || 0), Math.round(Number(rect.height) || 0)]
|
||||
: [0, 0],
|
||||
fullscreen: {
|
||||
active: Boolean(fullscreenElement),
|
||||
element: fullscreenElement
|
||||
? String(fullscreenElement.nodeName || "element").toLowerCase()
|
||||
: null,
|
||||
ownsMedia: Boolean(
|
||||
fullscreenElement &&
|
||||
(fullscreenElement === primary ||
|
||||
isComposedDescendant(primary, fullscreenElement))
|
||||
)
|
||||
},
|
||||
controller: {
|
||||
present: Boolean(controller && wrapper),
|
||||
connected: Boolean(wrapper && wrapper.isConnected),
|
||||
hidden: Boolean(wrapper && wrapper.classList.contains("vsc-hidden")),
|
||||
geometryHidden: Boolean(
|
||||
wrapper && wrapper.classList.contains("vsc-geometry-hidden")
|
||||
),
|
||||
fullscreenPopover: Boolean(
|
||||
wrapper && wrapper.classList.contains("vsc-fullscreen-popover")
|
||||
),
|
||||
location: controller ? controller.controllerLocation : null
|
||||
},
|
||||
effectiveSettings: {
|
||||
enabled: tc.settings.enabled !== false,
|
||||
tabPaused: tc.tabPaused === true,
|
||||
startHidden: tc.settings.startHidden === true,
|
||||
hideWithControls: tc.settings.hideWithControls === true,
|
||||
rememberSpeed: tc.settings.rememberSpeed === true,
|
||||
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed === true,
|
||||
shortcutTargetMode: tc.settings.shortcutTargetMode
|
||||
},
|
||||
siteRule: {
|
||||
matched: Boolean(tc.activeSiteRule),
|
||||
disabled: Boolean(
|
||||
tc.activeSiteRule &&
|
||||
siteRuleUtils.isSiteRuleDisabled(tc.activeSiteRule)
|
||||
),
|
||||
overrideKeys: tc.activeSiteRule
|
||||
? Object.keys(tc.activeSiteRule)
|
||||
.filter(function(key) {
|
||||
return key !== "pattern" && key !== "title";
|
||||
})
|
||||
.sort()
|
||||
: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
tabPaused: false,
|
||||
siteRuleBase: null,
|
||||
runtimeSettingsHydrated: false,
|
||||
pendingMediaCandidates: [],
|
||||
settingsReloadRetries: 0,
|
||||
lastPointerPosition: null,
|
||||
lastInteractedMedia: null,
|
||||
frameToken:
|
||||
window.crypto && typeof window.crypto.randomUUID === "function"
|
||||
? window.crypto.randomUUID()
|
||||
: String(Date.now()) + "-" + Math.random().toString(36).slice(2)
|
||||
};
|
||||
|
||||
function isSpeederActiveForCurrentPage() {
|
||||
return (
|
||||
tc.tabPaused !== true &&
|
||||
siteRuleUtils.isSpeederActiveForSite(
|
||||
tc.settings.enabled,
|
||||
tc.activeSiteRule
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function applyTabPausedState(paused) {
|
||||
tc.tabPaused = paused === true;
|
||||
if (!tc.runtimeSettingsHydrated) return;
|
||||
if (tc.tabPaused) {
|
||||
clearAllSpeedRestoreEnforcement();
|
||||
tc.mediaElements.slice().forEach(function(media) {
|
||||
removeController(media);
|
||||
});
|
||||
return;
|
||||
}
|
||||
initializeWhenReady(document, true);
|
||||
}
|
||||
|
||||
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: "", name: "Rewind", className: "rw" },
|
||||
slower: { label: "", name: "Decrease speed", className: "" },
|
||||
faster: { label: "", name: "Increase speed", className: "" },
|
||||
advance: { label: "", name: "Advance", className: "rw" },
|
||||
display: {
|
||||
label: "",
|
||||
name: "Show or hide controller",
|
||||
className: "hideButton"
|
||||
},
|
||||
reset: { label: "\u21BB", name: "Reset speed", className: "" },
|
||||
fast: { label: "", name: "Toggle preferred speed", className: "" },
|
||||
nudge: { label: "", name: "Toggle subtitle nudge", className: "" },
|
||||
pause: { label: "", name: "Play or pause", className: "" },
|
||||
muted: { label: "", name: "Mute or unmute", className: "" },
|
||||
louder: { label: "", name: "Increase volume", className: "" },
|
||||
softer: { label: "", name: "Decrease volume", className: "" },
|
||||
mark: { label: "", name: "Mark position", className: "" },
|
||||
jump: { label: "", name: "Jump to marked position", className: "" },
|
||||
settings: { label: "", name: "Open Speeder settings", 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 (!isSpeederActiveForCurrentPage()) {
|
||||
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 || {});
|
||||
if (chrome.runtime && typeof chrome.runtime.sendMessage === "function") {
|
||||
chrome.runtime.sendMessage({ action: "get_tab_pause_state" }, function(response) {
|
||||
if (!chrome.runtime.lastError && response) {
|
||||
applyTabPausedState(response.paused === true);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 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 === "set_tab_paused") {
|
||||
applyTabPausedState(request.paused === true);
|
||||
sendResponse({ paused: tc.tabPaused });
|
||||
return false;
|
||||
}
|
||||
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,
|
||||
frameToken: tc.frameToken,
|
||||
diagnostics: getDiagnosticsSnapshot(videoGs),
|
||||
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 (
|
||||
request.targetFrameToken &&
|
||||
request.targetFrameToken !== tc.frameToken
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!isSpeederActiveForCurrentPage()
|
||||
) {
|
||||
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");
|
||||
var name = controllerButtonDefs[action] && controllerButtonDefs[action].name;
|
||||
button.type = "button";
|
||||
button.dataset.action = action;
|
||||
button.setAttribute("aria-label", name || action);
|
||||
button.title = name || 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 enableDirectFullscreenPopover(videoController) {
|
||||
if (!videoController || !videoController.video || !videoController.div) {
|
||||
return false;
|
||||
}
|
||||
var wrapper = videoController.div;
|
||||
if (typeof wrapper.showPopover !== "function") return false;
|
||||
|
||||
var normalMount = 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;
|
||||
var ownsFullscreen = Boolean(
|
||||
fullscreenElement &&
|
||||
(fullscreenElement === video ||
|
||||
isComposedDescendant(video, fullscreenElement))
|
||||
);
|
||||
|
||||
if (ownsFullscreen) {
|
||||
targetMount = getControllerMount(video, fullscreenElement);
|
||||
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
|
||||
targetMount = getControllerMount(video);
|
||||
videoController.normalControllerMount = targetMount;
|
||||
}
|
||||
|
||||
if (!targetMount) return false;
|
||||
|
||||
if (fullscreenElement === video) {
|
||||
// A replaced <video> cannot paint author children, so direct-media
|
||||
// fullscreen is the only case that needs a separate top-layer popover.
|
||||
if (enableDirectFullscreenPopover(videoController)) 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 = createControllerButton(
|
||||
doc,
|
||||
btnId,
|
||||
controllerButtonDefs.nudge.label,
|
||||
controllerButtonDefs.nudge.className
|
||||
);
|
||||
subtitleNudgeIndicator.id = "nudge-indicator";
|
||||
subtitleNudgeIndicator.setAttribute("aria-live", "polite");
|
||||
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
|
||||
);
|
||||
});
|
||||
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 (!isSpeederActiveForCurrentPage()) {
|
||||
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 isEditableShortcutTarget(event) {
|
||||
var path =
|
||||
event && typeof event.composedPath === "function"
|
||||
? event.composedPath()
|
||||
: [event && event.target];
|
||||
|
||||
return path.some(function(target) {
|
||||
if (!target || target.nodeType !== 1) return false;
|
||||
var nodeName = target.nodeName;
|
||||
var role = target.getAttribute && target.getAttribute("role");
|
||||
var contentEditable =
|
||||
target.getAttribute && target.getAttribute("contenteditable");
|
||||
return (
|
||||
nodeName === "INPUT" ||
|
||||
nodeName === "TEXTAREA" ||
|
||||
nodeName === "SELECT" ||
|
||||
target.isContentEditable ||
|
||||
(contentEditable !== null && contentEditable !== "false") ||
|
||||
role === "textbox" ||
|
||||
role === "searchbox" ||
|
||||
role === "combobox" ||
|
||||
role === "spinbutton"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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 (isEditableShortcutTarget(event)) return;
|
||||
|
||||
if (
|
||||
!isSpeederActiveForCurrentPage()
|
||||
) {
|
||||
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 = isSpeederActiveForCurrentPage();
|
||||
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
(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;
|
||||
};
|
||||
});
|
||||
})();
|
||||
@@ -1,3 +1,51 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
/* Important declarations inside a shadow tree outrank important page styles.
|
||||
Repeat the top-layer positioning here so fullscreen cannot fall back to the
|
||||
base absolute host rule. */
|
||||
:host(.vsc-fullscreen-popover) {
|
||||
position: fixed !important;
|
||||
inset: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
:host(.vsc-fullscreen-popover)::backdrop {
|
||||
pointer-events: none !important;
|
||||
background: transparent !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;
|
||||
@@ -14,6 +62,7 @@
|
||||
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;
|
||||
@@ -55,6 +104,10 @@
|
||||
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;
|
||||
@@ -281,10 +334,15 @@ button .vsc-btn-icon svg {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
button:focus {
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Speeder",
|
||||
"short_name": "Speeder",
|
||||
"version": "5.1.8.0",
|
||||
"version": "6.0.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,30 +16,31 @@
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
"16": "assets/icons/icon16.png",
|
||||
"48": "assets/icons/icon48.png",
|
||||
"128": "assets/icons/icon128.png"
|
||||
},
|
||||
"background": {
|
||||
"scripts": [
|
||||
"background.js"
|
||||
"background/background.js"
|
||||
]
|
||||
},
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"storage",
|
||||
"https://cdn.jsdelivr.net/*"
|
||||
],
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"page": "options/options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"browser_action": {
|
||||
"default_icon": {
|
||||
"19": "icons/icon19.png",
|
||||
"38": "icons/icon38.png",
|
||||
"48": "icons/icon48.png"
|
||||
"19": "assets/icons/icon19.png",
|
||||
"38": "assets/icons/icon38.png",
|
||||
"48": "assets/icons/icon48.png"
|
||||
},
|
||||
"default_popup": "popup.html"
|
||||
"default_popup": "popup/popup.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
@@ -50,25 +51,28 @@
|
||||
"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": [
|
||||
"inject.css"
|
||||
"content/inject.css"
|
||||
],
|
||||
"js": [
|
||||
"shared/controller-utils.js",
|
||||
"shared/key-bindings.js",
|
||||
"shared/settings-core.js",
|
||||
"shared/site-rules.js",
|
||||
"ui-icons.js",
|
||||
"inject.js"
|
||||
"shared/ui-icons.js",
|
||||
"content/inject.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"inject.css",
|
||||
"shadow.css"
|
||||
"content/inject.css",
|
||||
"content/shadow-bridge.js",
|
||||
"content/shadow.css"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// 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(
|
||||
"Settings imported, but custom icons could not be updated - " +
|
||||
localError.message +
|
||||
". Reloading...",
|
||||
true
|
||||
);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
--text: #17191c;
|
||||
--muted: #626b76;
|
||||
--accent: #111827;
|
||||
--focus-ring: #2563eb;
|
||||
--switch-track-off: #c1cad6;
|
||||
--switch-track-off-border: #aeb8c5;
|
||||
--switch-track-on: #111827;
|
||||
@@ -177,9 +178,14 @@ a:visited {
|
||||
text-underline-offset: 0.14em;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus {
|
||||
color: #000;
|
||||
a:hover {
|
||||
color: var(--text);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -225,7 +231,7 @@ input[type="checkbox"]:focus-visible,
|
||||
input[type="text"]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -322,7 +328,7 @@ label em {
|
||||
|
||||
.shortcut-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 120px 120px;
|
||||
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
@@ -334,20 +340,45 @@ 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;
|
||||
@@ -489,7 +520,7 @@ label em {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
@@ -502,6 +533,11 @@ 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,
|
||||
@@ -594,6 +630,9 @@ label em {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 150ms ease, opacity 150ms ease;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cb-block:hover {
|
||||
@@ -871,8 +910,19 @@ 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;
|
||||
}
|
||||
|
||||
@@ -935,6 +985,10 @@ 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;
|
||||
@@ -969,16 +1023,22 @@ button.lucide-result-tile.lucide-picked {
|
||||
}
|
||||
|
||||
.site-shortcuts-container .shortcut-row {
|
||||
grid-template-columns: minmax(0, 1fr) 110px 110px minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) 110px 110px minmax(0, 1fr) 38px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.site-shortcuts-container .shortcut-row:first-child {
|
||||
.site-shortcuts-rows .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;
|
||||
@@ -1006,21 +1066,103 @@ 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-footer {
|
||||
padding: 16px 20px;
|
||||
.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;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.support-footer p {
|
||||
margin: 0;
|
||||
.support-cta-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.support-footer a {
|
||||
font-weight: 600;
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -1052,7 +1194,8 @@ button.lucide-result-tile.lucide-picked {
|
||||
}
|
||||
|
||||
.action-row button,
|
||||
#addShortcutSelector {
|
||||
#addShortcutSelector,
|
||||
.site-add-shortcut-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1060,6 +1203,10 @@ 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;
|
||||
}
|
||||
@@ -1085,6 +1232,10 @@ 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) {
|
||||
@@ -1097,6 +1248,7 @@ button.lucide-result-tile.lucide-picked {
|
||||
--text: #f2f4f6;
|
||||
--muted: #a0a8b2;
|
||||
--accent: #f2f4f6;
|
||||
--focus-ring: #93c5fd;
|
||||
--switch-track-off: #374151;
|
||||
--switch-track-off-border: #4b5563;
|
||||
--switch-track-on: #aab7c6;
|
||||
@@ -1136,6 +1288,12 @@ 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 {
|
||||
@@ -1162,4 +1320,21 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Speeder Settings</title>
|
||||
<link rel="stylesheet" href="options.css" />
|
||||
<script src="shared/controller-utils.js"></script>
|
||||
<script src="shared/key-bindings.js"></script>
|
||||
<script src="shared/popup-controls.js"></script>
|
||||
<script src="ui-icons.js"></script>
|
||||
<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="lucide-client.js"></script>
|
||||
<script src="options.js"></script>
|
||||
<script src="shared/import-export.js"></script>
|
||||
<script src="importExport.js"></script>
|
||||
<script src="../shared/import-export.js"></script>
|
||||
<script src="import-export.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
@@ -24,13 +25,83 @@
|
||||
</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 shortcut. Escape disables it.</p>
|
||||
<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>
|
||||
</div>
|
||||
<div class="shortcuts-grid">
|
||||
<div class="shortcut-row" id="display" data-action="display">
|
||||
@@ -66,7 +137,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="shortcut-row" id="slower" data-action="slower">
|
||||
<div class="shortcut-label">Decrease speed</div>
|
||||
<div class="shortcut-label">
|
||||
Decrease speed
|
||||
<em>Required: Speeder needs a key for this action.</em>
|
||||
</div>
|
||||
<input
|
||||
class="customKey"
|
||||
type="text"
|
||||
@@ -80,7 +154,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="shortcut-row" id="faster" data-action="faster">
|
||||
<div class="shortcut-label">Increase speed</div>
|
||||
<div class="shortcut-label">
|
||||
Increase speed
|
||||
<em>Required: Speeder needs a key for this action.</em>
|
||||
</div>
|
||||
<input
|
||||
class="customKey"
|
||||
type="text"
|
||||
@@ -173,7 +250,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select id="addShortcutSelector">
|
||||
<select id="addShortcutSelector" aria-label="Add another shortcut">
|
||||
<option value="">Add shortcut…</option>
|
||||
</select>
|
||||
</section>
|
||||
@@ -187,19 +264,48 @@
|
||||
<h4 class="defaults-sub-heading">General</h4>
|
||||
|
||||
<div class="row row-checkbox">
|
||||
<label for="enabled">Enable</label>
|
||||
<label for="enabled"
|
||||
>Enable<br />
|
||||
<em
|
||||
>On: site rules can block sites (blacklist). Off: only matched rules keep Speeder on (whitelist).</em
|
||||
>
|
||||
</label>
|
||||
<input id="enabled" type="checkbox" />
|
||||
</div>
|
||||
<div class="row row-checkbox">
|
||||
<label for="audioBoolean">Work on audio</label>
|
||||
<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>
|
||||
<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</label>
|
||||
<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 “Force last
|
||||
saved speed,” which fights players that reset rate.</em
|
||||
>
|
||||
</label>
|
||||
<input id="rememberSpeed" type="checkbox" />
|
||||
</div>
|
||||
<div class="row row-checkbox">
|
||||
@@ -212,16 +318,42 @@
|
||||
</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</label>
|
||||
<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>
|
||||
<input id="startHidden" type="checkbox" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="controllerLocation">Default controller location</label>
|
||||
<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>
|
||||
<select id="controllerLocation">
|
||||
<option value="top-left">Top left</option>
|
||||
<option value="top-center">Top center</option>
|
||||
@@ -234,7 +366,13 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="controllerOpacity">Controller opacity</label>
|
||||
<label for="controllerOpacity"
|
||||
>Controller opacity<br />
|
||||
<em
|
||||
>0–1 (decimals). Lower is more transparent. Applies to the
|
||||
in-page controller only.</em
|
||||
>
|
||||
</label>
|
||||
<input id="controllerOpacity" type="text" value="" />
|
||||
</div>
|
||||
<div class="row row-controller-margin">
|
||||
@@ -282,23 +420,33 @@
|
||||
<div class="row row-checkbox">
|
||||
<label for="enableSubtitleNudge"
|
||||
>Enable subtitle nudge<br /><em
|
||||
>Makes tiny playback changes to help keep subtitles aligned.</em
|
||||
>Makes tiny playback changes to help keep subtitles aligned and
|
||||
allows nudge toggle controls.</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: 10–1000. Smaller values are more
|
||||
frequent. Default: 50.</em
|
||||
>How often to nudge: 250–1000. Smaller values are more
|
||||
frequent. Default: 250.</em
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
id="subtitleNudgeInterval"
|
||||
type="text"
|
||||
value=""
|
||||
placeholder="50"
|
||||
placeholder="250"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -320,8 +468,8 @@
|
||||
<div class="section-heading">
|
||||
<h3>Hover control bar</h3>
|
||||
<p class="section-intro">
|
||||
Drag blocks to reorder. Move between Active and Available to
|
||||
show or hide buttons.
|
||||
Drag blocks or use arrow keys to reorder. Press a block to
|
||||
move it between Active and Available.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cb-editor">
|
||||
@@ -350,11 +498,23 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="row row-checkbox">
|
||||
<label for="showPopupControlBar">Show popup control bar</label>
|
||||
<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>
|
||||
<input id="showPopupControlBar" type="checkbox" />
|
||||
</div>
|
||||
<div class="row row-checkbox">
|
||||
<label for="popupMatchHoverControls">Match hover controls</label>
|
||||
<label for="popupMatchHoverControls"
|
||||
>Match hover controls<br />
|
||||
<em
|
||||
>When on, the popup copies the hover bar’s buttons and
|
||||
order. When off, customize the popup layout below.</em
|
||||
>
|
||||
</label>
|
||||
<input id="popupMatchHoverControls" type="checkbox" />
|
||||
</div>
|
||||
<div id="popupCbEditorWrap" class="cb-editor cb-editor-disabled">
|
||||
@@ -431,7 +591,7 @@
|
||||
<div
|
||||
id="lucideIconResults"
|
||||
class="lucide-icon-results"
|
||||
role="listbox"
|
||||
role="group"
|
||||
aria-label="Matching Lucide icons"
|
||||
></div>
|
||||
<p id="lucideIconStatus" class="lucide-icon-status" aria-live="polite"></p>
|
||||
@@ -473,7 +633,9 @@
|
||||
>
|
||||
patterns like <code>/(.+)youtube\.com(\/*)$/gi</code>. Turn on a
|
||||
row only when you want that group to override the general defaults
|
||||
above.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div id="siteRulesContainer"></div>
|
||||
@@ -492,29 +654,55 @@
|
||||
>
|
||||
<span class="site-rule-toggle-icon" aria-hidden="true">…</span>
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
class="site-pattern"
|
||||
placeholder="e.g., youtube.com or /regex/gi"
|
||||
/>
|
||||
<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>
|
||||
<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</span>
|
||||
<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)—same pairing as Defaults →
|
||||
Enable.</em
|
||||
></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</span>
|
||||
<span
|
||||
>Override placement for this site<br /><em
|
||||
>When on, location and margin below replace general
|
||||
defaults for matching URLs.</em
|
||||
></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:</label>
|
||||
<label
|
||||
>Default controller location:<br /><em
|
||||
>Corner or edge anchor for the hover bar. Replaces the
|
||||
general default for matching URLs only.</em
|
||||
></label
|
||||
>
|
||||
<select class="site-controllerLocation">
|
||||
<option value="top-left">Top left</option>
|
||||
<option value="top-center">Top center</option>
|
||||
@@ -529,7 +717,8 @@
|
||||
<div class="site-rule-option site-rule-margin-option">
|
||||
<label
|
||||
>Controller margin (px):<br /><em
|
||||
>Shifts the whole control. 0–200.</em
|
||||
>Shifts the whole control from its preset position (CSS
|
||||
margins). Top and bottom. 0–200.</em
|
||||
></label
|
||||
>
|
||||
<div class="controller-margin-inputs">
|
||||
@@ -547,85 +736,217 @@
|
||||
</div>
|
||||
<div class="site-rule-override-section">
|
||||
<label class="site-override-lead">
|
||||
<span>Override hide-by-default for this site</span>
|
||||
<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
|
||||
>
|
||||
<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:</label>
|
||||
<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
|
||||
>
|
||||
<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</span>
|
||||
<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
|
||||
>
|
||||
<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)</span>
|
||||
<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
|
||||
>
|
||||
<input type="checkbox" class="site-hideWithControls" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-field">
|
||||
<label>Auto-hide timer (0.1–15s):</label>
|
||||
<label
|
||||
>Auto-hide timer (0.1–15s):<br /><em
|
||||
>Seconds of inactivity before hiding: 0.1–15 for
|
||||
non-YouTube sites.</em
|
||||
></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</span>
|
||||
<span
|
||||
>Override playback for this site<br /><em
|
||||
>When on, remember speed / force / media eligibility
|
||||
below replace general defaults for matching URLs.</em
|
||||
></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:</label>
|
||||
<label
|
||||
>Remember playback speed:<br /><em
|
||||
>Stores speeds you set with Speeder per source so
|
||||
revisiting the same media can restore them. Separate
|
||||
from “Force last saved speed,” which fights
|
||||
players that reset rate.</em
|
||||
></label
|
||||
>
|
||||
<input type="checkbox" class="site-rememberSpeed" />
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label>Force last saved speed:</label>
|
||||
<label
|
||||
>Force last saved speed:<br /><em
|
||||
>Useful when a video player tries to override the speed
|
||||
you set in Speeder.</em
|
||||
></label
|
||||
>
|
||||
<input type="checkbox" class="site-forceLastSavedSpeed" />
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-field">
|
||||
<label
|
||||
>Preferred speed (0.1–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:</label>
|
||||
<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
|
||||
>
|
||||
<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</span>
|
||||
<span
|
||||
>Override opacity for this site<br /><em
|
||||
>When on, opacity below replaces the general default for
|
||||
matching URLs.</em
|
||||
></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:</label>
|
||||
<label
|
||||
>Controller opacity:<br /><em
|
||||
>0–1 (decimals). Lower is more transparent.
|
||||
Applies to the in-page controller only.</em
|
||||
></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</span>
|
||||
<span
|
||||
>Override subtitle nudge for this site<br /><em
|
||||
>When on, nudge options below replace general defaults for
|
||||
matching URLs.</em
|
||||
></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:</label>
|
||||
<label
|
||||
>Enable subtitle nudge:<br /><em
|
||||
>Makes tiny playback changes to help keep subtitles
|
||||
aligned.</em
|
||||
></label
|
||||
>
|
||||
<input type="checkbox" class="site-enableSubtitleNudge" />
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label
|
||||
>Start subtitle nudge enabled:<br /><em
|
||||
>When off, matching videos start with nudging disabled
|
||||
until you toggle it.</em
|
||||
></label
|
||||
>
|
||||
<input type="checkbox" class="site-subtitleNudgeEnabledByDefault" />
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-field">
|
||||
<label>Nudge interval (10–1000ms):</label>
|
||||
<input type="text" class="site-subtitleNudgeInterval" placeholder="50" />
|
||||
<label
|
||||
>Nudge interval (250–1000ms):<br /><em
|
||||
>How often to nudge: 250–1000. Smaller values are
|
||||
more frequent. Default: 250.</em
|
||||
></label
|
||||
>
|
||||
<input type="text" class="site-subtitleNudgeInterval" placeholder="250" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-rule-controlbar">
|
||||
<label class="site-override-lead">
|
||||
<span>Override in-player control bar for this site</span>
|
||||
<span
|
||||
>Override in-player control bar for this site<br /><em
|
||||
>Drag blocks or use the keyboard to arrange Active and
|
||||
Available buttons for matching URLs only.</em
|
||||
></span
|
||||
>
|
||||
<input type="checkbox" class="override-controlbar" />
|
||||
</label>
|
||||
<div class="site-controlbar-container">
|
||||
@@ -643,12 +964,22 @@
|
||||
</div>
|
||||
<div class="site-rule-controlbar">
|
||||
<label class="site-override-lead">
|
||||
<span>Override extension popup for this site</span>
|
||||
<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
|
||||
>
|
||||
<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</label>
|
||||
<label
|
||||
>Show popup control bar<br /><em
|
||||
>Shows buttons in the extension popup (toolbar icon).
|
||||
Replaces the general default for this pattern.</em
|
||||
></label
|
||||
>
|
||||
<input type="checkbox" class="site-showPopupControlBar" />
|
||||
</div>
|
||||
<div class="cb-editor">
|
||||
@@ -665,10 +996,23 @@
|
||||
</div>
|
||||
<div class="site-rule-shortcuts">
|
||||
<label class="site-override-lead">
|
||||
<span>Override shortcuts for this site</span>
|
||||
<span
|
||||
>Override shortcuts for this site<br /><em
|
||||
>A site shortcut replaces global bindings for the same
|
||||
action; other global shortcuts remain active.</em
|
||||
></span
|
||||
>
|
||||
<input type="checkbox" class="override-shortcuts" />
|
||||
</label>
|
||||
<div class="site-shortcuts-container"></div>
|
||||
<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…</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -678,13 +1022,14 @@
|
||||
<section class="settings-card action-card">
|
||||
<div class="section-heading">
|
||||
<h3>Actions</h3>
|
||||
<p class="section-intro">Save, restore, export, or import settings.</p>
|
||||
<p class="section-intro">Changes save automatically.</p>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="save">Save Changes</button>
|
||||
<button id="save">Save Now</button>
|
||||
<button id="restore">Restore Defaults</button>
|
||||
<button id="exportSettings">Export Settings</button>
|
||||
<button id="importSettings">Import Settings</button>
|
||||
<button id="copyDiagnostics">Copy Diagnostics</button>
|
||||
</div>
|
||||
|
||||
<div id="status" role="status" aria-live="polite"></div>
|
||||
@@ -693,32 +1038,14 @@
|
||||
<section id="faq" class="settings-card info-card">
|
||||
<h4>Extension controls not appearing?</h4>
|
||||
<p>
|
||||
This extension only works with HTML5 audio and video. If the
|
||||
controls never appear, you may be looking at Flash content instead.
|
||||
Right-click the player to check: if the menu mentions Flash, that
|
||||
is the issue. Most sites will fall back to HTML5 when Flash is not
|
||||
available, so disabling Flash in the browser can help.
|
||||
Speeder works with HTML5 video and, when enabled above, HTML5 audio.
|
||||
Check that the current site is enabled, then use the popup’s
|
||||
“Rescan page for videos” action after a player loads
|
||||
dynamically. Browser-internal pages and players that do not expose
|
||||
HTML5 media to extensions cannot be controlled.
|
||||
</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>
|
||||
@@ -74,11 +74,13 @@ var controllerButtonDefs = {
|
||||
reset: { icon: "\u21BB", name: "Reset speed" },
|
||||
fast: { icon: "\u2605", name: "Preferred speed" },
|
||||
nudge: { icon: "\u2713", name: "Subtitle nudge" },
|
||||
settings: { icon: "\u2699", name: "Settings" },
|
||||
pause: { icon: "\u23EF", name: "Pause / Play" },
|
||||
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" }
|
||||
jump: { icon: "\u21E5", name: "Jump to marker" },
|
||||
settings: { icon: "\u2699", name: "Settings" },
|
||||
};
|
||||
var popupExcludedButtonIds = new Set(["settings"]);
|
||||
|
||||
@@ -160,57 +162,96 @@ function createDefaultBinding(action, code, value) {
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
],
|
||||
controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
|
||||
showPopupControlBar: true,
|
||||
popupMatchHoverControls: true,
|
||||
popupControllerButtons: ["rewind", "slower", "faster", "advance", "display"],
|
||||
enableSubtitleNudge: false,
|
||||
subtitleNudgeInterval: 50,
|
||||
subtitleNudgeAmount: 0.001
|
||||
};
|
||||
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 || {});
|
||||
});
|
||||
}
|
||||
|
||||
const actionLabels = {
|
||||
display: "Show/hide controller",
|
||||
@@ -221,14 +262,17 @@ const actionLabels = {
|
||||
advance: "Advance",
|
||||
reset: "Reset speed",
|
||||
fast: "Preferred speed",
|
||||
muted: "Mute",
|
||||
pause: "Pause",
|
||||
toggleSubtitleNudge: "Toggle subtitle nudge",
|
||||
pause: "Play / Pause",
|
||||
muted: "Mute / Unmute",
|
||||
louder: "Increase volume",
|
||||
softer: "Decrease volume",
|
||||
mark: "Set marker",
|
||||
jump: "Jump to marker",
|
||||
toggleSubtitleNudge: "Toggle subtitle nudge"
|
||||
jump: "Jump to marker"
|
||||
};
|
||||
|
||||
const speedBindingActions = ["slower", "faster", "fast"];
|
||||
const speedBindingActions = ["slower", "faster", "fast", "softer", "louder"];
|
||||
const requiredShortcutActions = new Set(["slower", "faster"]);
|
||||
|
||||
function formatSpeedBindingDisplay(action, value) {
|
||||
if (!speedBindingActions.includes(action)) {
|
||||
@@ -241,6 +285,27 @@ 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",
|
||||
@@ -261,33 +326,66 @@ function refreshAddShortcutSelector() {
|
||||
selector.remove(1);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
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";
|
||||
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 };
|
||||
}
|
||||
}
|
||||
var def = tcDefaults.keyBindings.find(function (b) {
|
||||
return b.action === action;
|
||||
});
|
||||
if (def) {
|
||||
return {
|
||||
binding: normalizeStoredBinding(def),
|
||||
value: def.value
|
||||
};
|
||||
}
|
||||
return { binding: null, value: undefined };
|
||||
}
|
||||
|
||||
function ensureDefaultBinding(storage, action, code, value) {
|
||||
@@ -312,6 +410,19 @@ 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");
|
||||
@@ -445,6 +556,7 @@ function normalizeStoredBinding(binding, fallbackCode) {
|
||||
|
||||
var normalized = {
|
||||
code: normalizedCode,
|
||||
shiftKey: binding.shiftKey === true,
|
||||
disabled: false
|
||||
};
|
||||
|
||||
@@ -463,7 +575,7 @@ function formatBindingCode(code) {
|
||||
function getBindingLabel(binding) {
|
||||
if (!binding) return "";
|
||||
if (binding.disabled) return "";
|
||||
return formatBindingCode(binding.code);
|
||||
return (binding.shiftKey ? "Shift+" : "") + formatBindingCode(binding.code);
|
||||
}
|
||||
|
||||
function setShortcutInputBinding(input, binding) {
|
||||
@@ -476,6 +588,7 @@ function captureBindingFromEvent(event) {
|
||||
if (typeof event.code !== "string" || event.code.length === 0) return null;
|
||||
return {
|
||||
code: event.code,
|
||||
shiftKey: event.shiftKey === true,
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
@@ -485,6 +598,7 @@ function recordKeyPress(event) {
|
||||
|
||||
if (event.key === "Backspace") {
|
||||
setShortcutInputBinding(event.target, null);
|
||||
scheduleAutoSave();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
@@ -492,6 +606,7 @@ function recordKeyPress(event) {
|
||||
|
||||
if (event.key === "Escape") {
|
||||
setShortcutInputBinding(event.target, createDisabledBinding());
|
||||
scheduleAutoSave();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
@@ -501,6 +616,7 @@ function recordKeyPress(event) {
|
||||
if (!binding) return;
|
||||
|
||||
setShortcutInputBinding(event.target, binding);
|
||||
scheduleAutoSave();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
@@ -556,6 +672,20 @@ function appendSelectOptions(select, options) {
|
||||
});
|
||||
}
|
||||
|
||||
function labelShortcutRow(row) {
|
||||
if (!row) return;
|
||||
var action = row.dataset.action;
|
||||
var name = actionLabels[action] || action || "Shortcut";
|
||||
var keyInput = row.querySelector(".customKey");
|
||||
var valueInput = row.querySelector(".customValue");
|
||||
var removeButton = row.querySelector(".removeParent");
|
||||
if (keyInput) keyInput.setAttribute("aria-label", name + " key");
|
||||
if (valueInput) valueInput.setAttribute("aria-label", name + " value");
|
||||
if (removeButton) {
|
||||
removeButton.setAttribute("aria-label", "Remove " + name + " shortcut");
|
||||
}
|
||||
}
|
||||
|
||||
function add_shortcut(action, value) {
|
||||
if (!action) return;
|
||||
|
||||
@@ -580,7 +710,10 @@ function add_shortcut(action, value) {
|
||||
valueInput.value = "N/A";
|
||||
valueInput.disabled = true;
|
||||
} else {
|
||||
valueInput.value = formatSpeedBindingDisplay(action, value || 0);
|
||||
valueInput.value = formatSpeedBindingDisplay(
|
||||
action,
|
||||
resolveShortcutValue(action, value)
|
||||
);
|
||||
}
|
||||
|
||||
var removeButton = document.createElement("button");
|
||||
@@ -592,6 +725,7 @@ function add_shortcut(action, value) {
|
||||
div.appendChild(keyInput);
|
||||
div.appendChild(valueInput);
|
||||
div.appendChild(removeButton);
|
||||
labelShortcutRow(div);
|
||||
|
||||
var customsElement = document.querySelector(".shortcuts-grid");
|
||||
customsElement.appendChild(div);
|
||||
@@ -607,19 +741,54 @@ 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 " + action + " is invalid. Unable to save"
|
||||
message:
|
||||
"Error: Shortcut for " +
|
||||
(actionLabels[action] || action) +
|
||||
" cannot be empty. 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: customActionsNoValues.includes(action)
|
||||
? 0
|
||||
: Number(valueInput.value),
|
||||
value: bindingValue,
|
||||
force: false,
|
||||
predefined: predefined
|
||||
});
|
||||
@@ -639,9 +808,8 @@ function validate() {
|
||||
if (pattern.startsWith("/")) {
|
||||
try {
|
||||
var lastSlash = pattern.lastIndexOf("/");
|
||||
if (lastSlash > 0) {
|
||||
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
|
||||
}
|
||||
if (lastSlash === 0) throw new Error("Missing closing slash");
|
||||
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
|
||||
} catch (err) {
|
||||
status.textContent =
|
||||
"Error: Invalid site rule regex: " + pattern + ". Unable to save";
|
||||
@@ -654,11 +822,19 @@ function validate() {
|
||||
return valid;
|
||||
}
|
||||
|
||||
function save_options() {
|
||||
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;
|
||||
}
|
||||
if (validate() === false) return;
|
||||
|
||||
keyBindings = [];
|
||||
var status = document.getElementById("status");
|
||||
var saveError = null;
|
||||
|
||||
// Collect shortcuts from the main shortcuts section (both default and custom)
|
||||
@@ -678,8 +854,14 @@ function save_options() {
|
||||
settings.forceLastSavedSpeed =
|
||||
document.getElementById("forceLastSavedSpeed").checked;
|
||||
settings.audioBoolean = document.getElementById("audioBoolean").checked;
|
||||
settings.showAmbientLoopControls =
|
||||
document.getElementById("showAmbientLoopControls").checked;
|
||||
settings.enabled = document.getElementById("enabled").checked;
|
||||
settings.startHidden = document.getElementById("startHidden").checked;
|
||||
settings.shortcutTargetMode =
|
||||
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));
|
||||
@@ -711,13 +893,14 @@ function save_options() {
|
||||
settings.keyBindings = keyBindings;
|
||||
settings.enableSubtitleNudge =
|
||||
document.getElementById("enableSubtitleNudge").checked;
|
||||
settings.subtitleNudgeEnabledByDefault =
|
||||
document.getElementById("subtitleNudgeEnabledByDefault").checked;
|
||||
settings.subtitleNudgeInterval =
|
||||
parseInt(document.getElementById("subtitleNudgeInterval").value, 10) ||
|
||||
tcDefaults.subtitleNudgeInterval;
|
||||
settings.subtitleNudgeAmount = tcDefaults.subtitleNudgeAmount;
|
||||
|
||||
if (settings.subtitleNudgeInterval < 10) {
|
||||
settings.subtitleNudgeInterval = 10;
|
||||
if (settings.subtitleNudgeInterval < 250) {
|
||||
settings.subtitleNudgeInterval = 250;
|
||||
}
|
||||
if (settings.subtitleNudgeInterval > 1000) {
|
||||
settings.subtitleNudgeInterval = 1000;
|
||||
@@ -736,11 +919,25 @@ function save_options() {
|
||||
var pattern = ruleEl.querySelector(".site-pattern").value.trim();
|
||||
if (pattern.length === 0) return;
|
||||
|
||||
var rule = { pattern: pattern };
|
||||
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;
|
||||
|
||||
// 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
|
||||
@@ -759,10 +956,16 @@ function save_options() {
|
||||
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) {
|
||||
@@ -774,13 +977,33 @@ function save_options() {
|
||||
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) {
|
||||
@@ -789,11 +1012,15 @@ function save_options() {
|
||||
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
|
||||
@@ -801,10 +1028,14 @@ function save_options() {
|
||||
rule.subtitleNudgeInterval = Math.min(
|
||||
1000,
|
||||
Math.max(
|
||||
10,
|
||||
250,
|
||||
Number.isFinite(nudgeIv) ? nudgeIv : settings.subtitleNudgeInterval
|
||||
)
|
||||
);
|
||||
} else {
|
||||
delete rule.enableSubtitleNudge;
|
||||
delete rule.subtitleNudgeEnabledByDefault;
|
||||
delete rule.subtitleNudgeInterval;
|
||||
}
|
||||
|
||||
if (ruleEl.querySelector(".override-controlbar").checked) {
|
||||
@@ -812,6 +1043,8 @@ function save_options() {
|
||||
if (activeZone) {
|
||||
rule.controllerButtons = readControlBarOrder(activeZone);
|
||||
}
|
||||
} else {
|
||||
delete rule.controllerButtons;
|
||||
}
|
||||
|
||||
if (ruleEl.querySelector(".override-popup-controlbar").checked) {
|
||||
@@ -823,49 +1056,91 @@ function save_options() {
|
||||
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) {
|
||||
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) {
|
||||
if (requiredShortcutActions.has(action)) {
|
||||
saveError =
|
||||
"Error: Site rule shortcut for " +
|
||||
(actionLabels[action] || action) +
|
||||
" cannot be empty. Unable to save";
|
||||
return;
|
||||
}
|
||||
binding = createDisabledBinding();
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// Legacy keys to remove
|
||||
const legacyKeys = [
|
||||
"resetSpeed", "speedStep", "fastSpeed", "rewindTime", "advanceTime",
|
||||
"resetKeyCode", "slowerKeyCode", "fasterKeyCode", "rewindKeyCode",
|
||||
"advanceKeyCode", "fastKeyCode", "blacklist"
|
||||
];
|
||||
if (saveError) {
|
||||
status.textContent = saveError;
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.storage.sync.remove(legacyKeys, function () {
|
||||
chrome.storage.sync.set(settings, function () {
|
||||
status.textContent = "Options saved";
|
||||
setTimeout(function () {
|
||||
status.textContent = "";
|
||||
}, 1000);
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -875,39 +1150,16 @@ function ensureAllDefaultBindings(storage) {
|
||||
});
|
||||
}
|
||||
|
||||
function addSiteRuleShortcut(container, action, binding, value, force) {
|
||||
function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
|
||||
if (!rowsEl) return;
|
||||
|
||||
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") {
|
||||
// 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)";
|
||||
}
|
||||
}
|
||||
actionLabel.textContent = actionLabelText;
|
||||
actionLabel.textContent = actionLabels[action] || action;
|
||||
|
||||
var keyInput = document.createElement("input");
|
||||
keyInput.className = "customKey";
|
||||
@@ -923,7 +1175,10 @@ function addSiteRuleShortcut(container, action, binding, value, force) {
|
||||
valueInput.value = "N/A";
|
||||
valueInput.disabled = true;
|
||||
} else {
|
||||
valueInput.value = formatSpeedBindingDisplay(action, value || 0);
|
||||
valueInput.value = formatSpeedBindingDisplay(
|
||||
action,
|
||||
resolveShortcutValue(action, value)
|
||||
);
|
||||
}
|
||||
|
||||
var forceLabel = document.createElement("label");
|
||||
@@ -942,21 +1197,60 @@ function addSiteRuleShortcut(container, 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);
|
||||
labelShortcutRow(div);
|
||||
|
||||
container.appendChild(div);
|
||||
rowsEl.appendChild(div);
|
||||
}
|
||||
|
||||
var siteRuleControlId = 0;
|
||||
|
||||
function associateSiteRuleLabels(ruleEl) {
|
||||
ruleEl.querySelectorAll(".site-rule-option").forEach(function(option) {
|
||||
var label = Array.from(option.children).find(function(child) {
|
||||
return child.tagName === "LABEL";
|
||||
});
|
||||
var controls = option.querySelectorAll("input, select, textarea");
|
||||
if (!label || controls.length !== 1 || label.contains(controls[0])) return;
|
||||
controls[0].id = "site-rule-control-" + ++siteRuleControlId;
|
||||
label.htmlFor = controls[0].id;
|
||||
});
|
||||
|
||||
ruleEl.querySelectorAll(".margin-pad-cell").forEach(function(cell) {
|
||||
var input = cell.querySelector("input");
|
||||
var miniLabel = cell.querySelector(".margin-pad-mini");
|
||||
if (input && miniLabel) {
|
||||
input.setAttribute(
|
||||
"aria-label",
|
||||
"Controller margin " +
|
||||
(miniLabel.textContent === "T" ? "top" : "bottom")
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
var removeRule = ruleEl.querySelector(".remove-site-rule");
|
||||
if (removeRule) removeRule.setAttribute("aria-label", "Remove site rule");
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -987,6 +1281,17 @@ 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",
|
||||
@@ -1020,12 +1325,19 @@ function createSiteRule(rule) {
|
||||
rule &&
|
||||
(rule.rememberSpeed !== undefined ||
|
||||
rule.forceLastSavedSpeed !== undefined ||
|
||||
rule.audioBoolean !== undefined)
|
||||
rule.preferredSpeed !== undefined ||
|
||||
rule.audioBoolean !== undefined ||
|
||||
rule.showAmbientLoopControls !== 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(
|
||||
@@ -1037,10 +1349,12 @@ 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,
|
||||
@@ -1085,61 +1399,31 @@ function createSiteRule(rule) {
|
||||
rule && Array.isArray(rule.shortcuts) && rule.shortcuts.length > 0
|
||||
);
|
||||
ruleEl.querySelector(".override-shortcuts").checked = hasShortcutOverride;
|
||||
var container = ruleEl.querySelector(".site-shortcuts-container");
|
||||
var rowsEl = ruleEl.querySelector(".site-shortcuts-rows");
|
||||
if (hasShortcutOverride) {
|
||||
rule.shortcuts.forEach((shortcut) => {
|
||||
addSiteRuleShortcut(
|
||||
container,
|
||||
rowsEl,
|
||||
shortcut.action,
|
||||
shortcut,
|
||||
shortcut.value,
|
||||
shortcut.force
|
||||
);
|
||||
});
|
||||
} else {
|
||||
populateDefaultSiteShortcuts(container);
|
||||
}
|
||||
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
|
||||
refreshSiteRuleAddShortcutSelector(ruleEl);
|
||||
associateSiteRuleLabels(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;
|
||||
|
||||
var block = document.createElement("div");
|
||||
var block = document.createElement("button");
|
||||
block.type = "button";
|
||||
block.className = "cb-block";
|
||||
block.dataset.buttonId = buttonId;
|
||||
block.draggable = true;
|
||||
@@ -1162,6 +1446,25 @@ function createControlBarBlock(buttonId) {
|
||||
return block;
|
||||
}
|
||||
|
||||
function updateControlBarBlockLabels(editor) {
|
||||
if (!editor) return;
|
||||
editor.querySelectorAll(".cb-dropzone").forEach(function(zone) {
|
||||
var state = zone.classList.contains("cb-active-zone")
|
||||
? "active"
|
||||
: "available";
|
||||
zone.querySelectorAll(".cb-block").forEach(function(block) {
|
||||
var def = controllerButtonDefs[block.dataset.buttonId];
|
||||
block.setAttribute(
|
||||
"aria-label",
|
||||
(def ? def.name : block.dataset.buttonId) +
|
||||
", " +
|
||||
state +
|
||||
". Press Enter to move; use arrow keys to reorder."
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) {
|
||||
vscClearElement(activeZone);
|
||||
vscClearElement(availableZone);
|
||||
@@ -1184,6 +1487,8 @@ function populateControlBarZones(activeZone, availableZone, activeIds, allowButt
|
||||
if (block) availableZone.appendChild(block);
|
||||
}
|
||||
});
|
||||
|
||||
updateControlBarBlockLabels(activeZone.closest(".cb-editor"));
|
||||
}
|
||||
|
||||
function readControlBarOrder(activeZone) {
|
||||
@@ -1227,11 +1532,11 @@ function updatePopupEditorDisabledState() {
|
||||
var checkbox = document.getElementById("popupMatchHoverControls");
|
||||
var wrap = document.getElementById("popupCbEditorWrap");
|
||||
if (!checkbox || !wrap) return;
|
||||
if (checkbox.checked) {
|
||||
wrap.classList.add("cb-editor-disabled");
|
||||
} else {
|
||||
wrap.classList.remove("cb-editor-disabled");
|
||||
}
|
||||
wrap.classList.toggle("cb-editor-disabled", checkbox.checked);
|
||||
wrap.querySelectorAll(".cb-block").forEach(function(block) {
|
||||
block.disabled = checkbox.checked;
|
||||
block.draggable = !checkbox.checked;
|
||||
});
|
||||
}
|
||||
|
||||
function getDragAfterElement(container, x, y) {
|
||||
@@ -1254,6 +1559,8 @@ function getDragAfterElement(container, x, y) {
|
||||
}
|
||||
|
||||
function initControlBarEditor() {
|
||||
if (document.vscControlBarEditorInitialized) return;
|
||||
document.vscControlBarEditorInitialized = true;
|
||||
var draggedBlock = null;
|
||||
|
||||
function clearControlBarDropTargets(activeZone) {
|
||||
@@ -1266,7 +1573,7 @@ function initControlBarEditor() {
|
||||
|
||||
document.addEventListener("dragstart", function (e) {
|
||||
var block = e.target.closest(".cb-block");
|
||||
if (!block) return;
|
||||
if (!block || block.disabled) return;
|
||||
draggedBlock = block;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", block.dataset.buttonId);
|
||||
@@ -1311,10 +1618,49 @@ function initControlBarEditor() {
|
||||
var zone = e.target.closest(".cb-dropzone");
|
||||
if (zone) {
|
||||
e.preventDefault();
|
||||
updateControlBarBlockLabels(zone.closest(".cb-editor"));
|
||||
scheduleAutoSave();
|
||||
}
|
||||
|
||||
clearControlBarDropTargets(null);
|
||||
});
|
||||
|
||||
document.addEventListener("click", function(e) {
|
||||
var block = e.target.closest ? e.target.closest(".cb-block") : null;
|
||||
if (!block || block.disabled) return;
|
||||
var editor = block.closest(".cb-editor");
|
||||
var currentZone = block.closest(".cb-dropzone");
|
||||
if (!editor || !currentZone) return;
|
||||
var otherZone = editor.querySelector(
|
||||
currentZone.classList.contains("cb-active-zone")
|
||||
? ".cb-available-zone"
|
||||
: ".cb-active-zone"
|
||||
);
|
||||
if (!otherZone) return;
|
||||
otherZone.appendChild(block);
|
||||
updateControlBarBlockLabels(editor);
|
||||
block.focus();
|
||||
scheduleAutoSave();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function(e) {
|
||||
var block = e.target.closest ? e.target.closest(".cb-block") : null;
|
||||
if (!block || block.disabled) return;
|
||||
var previous = e.key === "ArrowLeft" || e.key === "ArrowUp";
|
||||
var next = e.key === "ArrowRight" || e.key === "ArrowDown";
|
||||
if (!previous && !next) return;
|
||||
var sibling = previous ? block.previousElementSibling : block.nextElementSibling;
|
||||
if (!sibling) return;
|
||||
e.preventDefault();
|
||||
if (previous) {
|
||||
block.parentNode.insertBefore(block, sibling);
|
||||
} else {
|
||||
block.parentNode.insertBefore(block, sibling.nextElementSibling);
|
||||
}
|
||||
updateControlBarBlockLabels(block.closest(".cb-editor"));
|
||||
block.focus();
|
||||
scheduleAutoSave();
|
||||
});
|
||||
}
|
||||
|
||||
var lucidePickerSelectedSlug = null;
|
||||
@@ -1386,6 +1732,10 @@ function initLucideButtonIconsUI() {
|
||||
b.dataset.slug = slug;
|
||||
b.title = slug;
|
||||
b.setAttribute("aria-label", slug);
|
||||
b.setAttribute(
|
||||
"aria-pressed",
|
||||
slug === lucidePickerSelectedSlug ? "true" : "false"
|
||||
);
|
||||
if (slug === lucidePickerSelectedSlug) {
|
||||
b.classList.add("lucide-picked");
|
||||
}
|
||||
@@ -1407,6 +1757,10 @@ function initLucideButtonIconsUI() {
|
||||
resultsEl.querySelectorAll("button"),
|
||||
function (x) {
|
||||
x.classList.toggle("lucide-picked", x.dataset.slug === slug);
|
||||
x.setAttribute(
|
||||
"aria-pressed",
|
||||
x.dataset.slug === slug ? "true" : "false"
|
||||
);
|
||||
}
|
||||
);
|
||||
fetchLucideSvg(slug)
|
||||
@@ -1472,7 +1826,7 @@ function initLucideButtonIconsUI() {
|
||||
slug +
|
||||
" for " +
|
||||
action +
|
||||
". Reload pages for the hover bar."
|
||||
". Open pages update automatically."
|
||||
);
|
||||
});
|
||||
})
|
||||
@@ -1521,11 +1875,26 @@ function initLucideButtonIconsUI() {
|
||||
}
|
||||
}
|
||||
|
||||
function restore_options() {
|
||||
chrome.storage.sync.get(tcDefaults, function (storage) {
|
||||
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 || {});
|
||||
chrome.storage.local.get(["customButtonIcons"], function (loc) {
|
||||
var localError = chrome.runtime.lastError;
|
||||
customButtonIconsLive =
|
||||
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
|
||||
!localError &&
|
||||
loc &&
|
||||
loc.customButtonIcons &&
|
||||
typeof loc.customButtonIcons === "object"
|
||||
? loc.customButtonIcons
|
||||
: {};
|
||||
|
||||
@@ -1533,15 +1902,15 @@ function restore_options() {
|
||||
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";
|
||||
|
||||
// Migration/Normalization for hideWithControls
|
||||
const hideWithControls = typeof storage.hideWithControls !== "undefined"
|
||||
? storage.hideWithControls
|
||||
: storage.hideWithYouTubeControls;
|
||||
|
||||
document.getElementById("hideWithControls").checked = hideWithControls;
|
||||
document.getElementById("hideWithControls").checked =
|
||||
storage.hideWithControls;
|
||||
document.getElementById("hideWithControlsTimer").value =
|
||||
storage.hideWithControlsTimer || tcDefaults.hideWithControlsTimer;
|
||||
|
||||
@@ -1557,6 +1926,8 @@ function restore_options() {
|
||||
storage.showPopupControlBar !== false;
|
||||
document.getElementById("enableSubtitleNudge").checked =
|
||||
storage.enableSubtitleNudge;
|
||||
document.getElementById("subtitleNudgeEnabledByDefault").checked =
|
||||
storage.subtitleNudgeEnabledByDefault;
|
||||
document.getElementById("subtitleNudgeInterval").value =
|
||||
storage.subtitleNudgeInterval;
|
||||
|
||||
@@ -1568,13 +1939,16 @@ function restore_options() {
|
||||
|
||||
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) {
|
||||
if (!row || usedDefaultShortcutRows.has(item.action)) {
|
||||
add_shortcut(item.action, item.value);
|
||||
row = document.querySelector(".shortcut-row.customs:last-of-type");
|
||||
} else {
|
||||
usedDefaultShortcutRows.add(item.action);
|
||||
}
|
||||
|
||||
if (!row) return;
|
||||
@@ -1597,11 +1971,7 @@ function restore_options() {
|
||||
|
||||
refreshAddShortcutSelector();
|
||||
|
||||
// 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 || [];
|
||||
var siteRules = Array.isArray(storage.siteRules) ? storage.siteRules : [];
|
||||
|
||||
vscClearElement(document.getElementById("siteRulesContainer"));
|
||||
if (siteRules.length > 0) {
|
||||
@@ -1627,25 +1997,94 @@ function restore_options() {
|
||||
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() {
|
||||
document.querySelectorAll(".customs:not([id])").forEach((el) => el.remove());
|
||||
if (
|
||||
!window.confirm(
|
||||
"Restore all defaults? This removes saved preferences, remembered speeds, and custom icons."
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
var status = document.getElementById("status");
|
||||
var restoreButton = document.getElementById("restore");
|
||||
setOptionsSyncSettingsLoaded(false);
|
||||
if (restoreButton) restoreButton.disabled = true;
|
||||
|
||||
chrome.storage.local.remove(
|
||||
["customButtonIcons", "lucideTagsCacheV1", "lucideTagsCacheV1At"],
|
||||
function () {}
|
||||
);
|
||||
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.sync.set(tcDefaults, function () {
|
||||
restore_options();
|
||||
var status = document.getElementById("status");
|
||||
status.textContent = "Default options restored";
|
||||
setTimeout(function () {
|
||||
status.textContent = "";
|
||||
}, 1000);
|
||||
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);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1656,6 +2095,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
versionElement.textContent = manifest.version;
|
||||
}
|
||||
|
||||
document.querySelectorAll("#customs .shortcut-row").forEach(labelShortcutRow);
|
||||
|
||||
restore_options();
|
||||
initControlBarEditor();
|
||||
|
||||
@@ -1663,6 +2104,41 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
.addEventListener("change", updatePopupEditorDisabledState);
|
||||
|
||||
document.getElementById("save").addEventListener("click", save_options);
|
||||
document.getElementById("copyDiagnostics").addEventListener("click", function () {
|
||||
var status = document.getElementById("status");
|
||||
if (
|
||||
!navigator.clipboard ||
|
||||
typeof navigator.clipboard.writeText !== "function"
|
||||
) {
|
||||
status.textContent = "Clipboard access is unavailable.";
|
||||
return;
|
||||
}
|
||||
var report = popupControlUtils.buildDiagnosticReport({
|
||||
speederVersion: manifest.version,
|
||||
browser: navigator.userAgent,
|
||||
platform: navigator.platform || null,
|
||||
storage: {
|
||||
enabled: document.getElementById("enabled").checked,
|
||||
rememberSpeed: document.getElementById("rememberSpeed").checked,
|
||||
forceLastSavedSpeed:
|
||||
document.getElementById("forceLastSavedSpeed").checked,
|
||||
audioBoolean: document.getElementById("audioBoolean").checked,
|
||||
startHidden: document.getElementById("startHidden").checked,
|
||||
hideWithControls: document.getElementById("hideWithControls").checked,
|
||||
controllerLocation: document.getElementById("controllerLocation").value,
|
||||
shortcutTargetMode:
|
||||
document.getElementById("shortcutTargetMode").value
|
||||
}
|
||||
});
|
||||
navigator.clipboard.writeText(report).then(
|
||||
function() {
|
||||
status.textContent = "Diagnostics copied. Review before sharing.";
|
||||
},
|
||||
function() {
|
||||
status.textContent = "Could not copy diagnostics.";
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const addSelector = document.getElementById("addShortcutSelector");
|
||||
if (addSelector) {
|
||||
@@ -1708,13 +2184,20 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
var removeParentButton = targetEl.closest(".removeParent");
|
||||
if (removeParentButton) {
|
||||
removeParentButton.parentNode.remove();
|
||||
var removedRow = removeParentButton.parentNode;
|
||||
var siteRuleForShortcut = removedRow.closest(".site-rule");
|
||||
removedRow.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");
|
||||
@@ -1736,6 +2219,26 @@ 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",
|
||||
@@ -1757,8 +2260,19 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,9 @@
|
||||
--border: #e2e5e9;
|
||||
--border-strong: #d4d9e0;
|
||||
--text: #17191c;
|
||||
--muted: #626b76;
|
||||
--accent: #111827;
|
||||
--muted: #626b76;
|
||||
--accent: #111827;
|
||||
--focus-ring: #2563eb;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -83,10 +84,15 @@ button:active {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
#refresh {
|
||||
background: var(--accent);
|
||||
@@ -94,10 +100,30 @@ 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;
|
||||
}
|
||||
|
||||
#pauseTab[aria-pressed="true"] {
|
||||
background: #fff3d6;
|
||||
border-color: #d8b66b;
|
||||
color: #704f0d;
|
||||
}
|
||||
|
||||
.popup-divider {
|
||||
height: 1px;
|
||||
@@ -210,21 +236,63 @@ button:focus-visible {
|
||||
|
||||
.donate-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.donate-split button {
|
||||
.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 var(--focus-ring);
|
||||
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;
|
||||
width: auto;
|
||||
border-radius: 0;
|
||||
min-height: 28px;
|
||||
max-width: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.donate-split button:first-child {
|
||||
.donate-icon-btn:first-child {
|
||||
border-radius: 8px 0 0 8px;
|
||||
border-right: 0;
|
||||
border-right-width: 0;
|
||||
}
|
||||
|
||||
.donate-split button:last-child {
|
||||
.donate-icon-btn:nth-child(2) {
|
||||
border-radius: 0;
|
||||
border-right-width: 0;
|
||||
}
|
||||
|
||||
.donate-icon-btn:last-child {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
@@ -239,8 +307,9 @@ button:focus-visible {
|
||||
--border: #2b3138;
|
||||
--border-strong: #3a414a;
|
||||
--text: #f2f4f6;
|
||||
--muted: #a0a8b2;
|
||||
--accent: #f2f4f6;
|
||||
--muted: #a0a8b2;
|
||||
--accent: #f2f4f6;
|
||||
--focus-ring: #93c5fd;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -262,8 +331,23 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<!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>
|
||||
<button
|
||||
id="pauseTab"
|
||||
class="popup-compact-action"
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
>Pause on this tab</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"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div class="popup-links">
|
||||
<button id="config">Settings</button>
|
||||
<div class="popup-secondary">
|
||||
<button id="addSiteRule" class="secondary">Add current site</button>
|
||||
<button id="copyDiagnostics" class="secondary">Copy diagnostics</button>
|
||||
<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>
|
||||
@@ -0,0 +1,730 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (window.vscPopupInitialized) return;
|
||||
window.vscPopupInitialized = true;
|
||||
var speederShared =
|
||||
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
|
||||
var siteRuleUtils = speederShared.siteRules || {};
|
||||
var popupControlUtils = speederShared.popupControls || {};
|
||||
|
||||
/* `label` is only used if shared/ui-icons.js has no path for this action (fallback). */
|
||||
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: "" }
|
||||
};
|
||||
|
||||
var defaultButtons = ["rewind", "slower", "faster", "advance", "display"];
|
||||
var popupExcludedButtonIds = new Set(["settings"]);
|
||||
var renderToken = 0;
|
||||
var forceLastSavedSpeedControlledBySiteRule = null;
|
||||
var selectedFrameToken = null;
|
||||
var shortcutTargetMode = "closest";
|
||||
var diagnosticContext = null;
|
||||
|
||||
function updateTabPauseButton(paused) {
|
||||
var button = document.querySelector("#pauseTab");
|
||||
button.setAttribute("aria-pressed", paused ? "true" : "false");
|
||||
button.textContent = paused ? "Resume on this tab" : "Pause on this tab";
|
||||
}
|
||||
|
||||
function getTabPauseState(tab, callback) {
|
||||
if (!tab || !Number.isInteger(tab.id)) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: "get_tab_pause_state", tabId: tab.id },
|
||||
function(response) {
|
||||
callback(
|
||||
!chrome.runtime.lastError && response && response.paused === true
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentSiteRuleDetails(url) {
|
||||
try {
|
||||
var parsed = new URL(url);
|
||||
if (
|
||||
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
||||
!parsed.host
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: parsed.host,
|
||||
pattern: parsed.protocol + "//" + parsed.host
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiagnosticReport() {
|
||||
if (!diagnosticContext) return null;
|
||||
return popupControlUtils.buildDiagnosticReport({
|
||||
speederVersion: chrome.runtime.getManifest().version,
|
||||
browser: navigator.userAgent,
|
||||
platform: navigator.platform || null,
|
||||
url: diagnosticContext.url,
|
||||
storage: diagnosticContext.storage,
|
||||
siteRule: diagnosticContext.siteRule,
|
||||
siteRuleDisabled: isSiteRuleDisabled(diagnosticContext.siteRule),
|
||||
tabPaused: diagnosticContext.tabPaused,
|
||||
frame: diagnosticContext.frame
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function isSiteRuleDisabled(rule) {
|
||||
return siteRuleUtils.isSiteRuleDisabled(rule);
|
||||
}
|
||||
|
||||
function resolvePopupButtons(storage, siteRule) {
|
||||
return popupControlUtils.resolvePopupButtons(storage, siteRule, {
|
||||
controllerButtonDefs: controllerButtonDefs,
|
||||
defaultButtons: defaultButtons,
|
||||
excludedIds: popupExcludedButtonIds
|
||||
});
|
||||
}
|
||||
|
||||
function setControlBarVisible(visible) {
|
||||
var bar = document.getElementById("popupControlBar");
|
||||
var dividers = document.querySelectorAll(".popup-divider");
|
||||
if (bar) bar.style.display = visible ? "" : "none";
|
||||
dividers.forEach(function (d) { d.style.display = visible ? "" : "none"; });
|
||||
}
|
||||
|
||||
function sendToActiveTab(message, callback) {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
||||
if (tabs[0] && tabs[0].id) {
|
||||
chrome.tabs.sendMessage(tabs[0].id, message, function (response) {
|
||||
if (chrome.runtime.lastError) {
|
||||
if (callback) callback(null);
|
||||
} else {
|
||||
if (callback) callback(response);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (callback) callback(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveTabContext(callback) {
|
||||
function finish(context) {
|
||||
getTabPauseState(context.tab, function(paused) {
|
||||
context.tabPaused = paused;
|
||||
if (callback) callback(context);
|
||||
});
|
||||
}
|
||||
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
||||
var activeTab = tabs && tabs[0] ? tabs[0] : null;
|
||||
if (!activeTab || !activeTab.id) {
|
||||
finish({ tab: null, url: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
|
||||
if (tabUrl.length > 0) {
|
||||
finish({ tab: activeTab, url: tabUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.tabs.sendMessage(
|
||||
activeTab.id,
|
||||
{ action: "get_page_context" },
|
||||
function (response) {
|
||||
if (chrome.runtime.lastError) {
|
||||
finish({ tab: activeTab, url: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
var pageUrl =
|
||||
response && typeof response.url === "string" ? response.url : "";
|
||||
finish({ tab: activeTab, url: pageUrl });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSpeedDisplay(speed) {
|
||||
var el = document.getElementById("popupSpeed");
|
||||
if (el) el.textContent = (speed != null ? Number(speed) : 1).toFixed(2);
|
||||
}
|
||||
|
||||
function applySpeedAndResetFromResponse(response) {
|
||||
if (response && response.speed != null) {
|
||||
updateSpeedDisplay(response.speed);
|
||||
}
|
||||
selectedFrameToken =
|
||||
response && typeof response.frameToken === "string"
|
||||
? response.frameToken
|
||||
: null;
|
||||
}
|
||||
|
||||
function pickBestFrameSpeedResult(results) {
|
||||
return popupControlUtils.pickBestFrameSpeedResult(results);
|
||||
}
|
||||
|
||||
function querySpeed(callback) {
|
||||
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" },
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buildControlBar(buttons, customIconsMap) {
|
||||
var bar = document.getElementById("popupControlBar");
|
||||
if (!bar) return;
|
||||
|
||||
var existing = bar.querySelectorAll("button");
|
||||
existing.forEach(function (btn) { btn.remove(); });
|
||||
|
||||
var customMap = customIconsMap || {};
|
||||
|
||||
buttons.forEach(function (btnId) {
|
||||
var def = controllerButtonDefs[btnId];
|
||||
if (!def) return;
|
||||
|
||||
var btn = document.createElement("button");
|
||||
btn.dataset.action = btnId;
|
||||
var customEntry = customMap[btnId];
|
||||
if (customEntry && customEntry.svg) {
|
||||
var customSpan = vscCreateSvgWrap(document, customEntry.svg, "vsc-btn-icon");
|
||||
if (customSpan) {
|
||||
btn.appendChild(customSpan);
|
||||
} else {
|
||||
btn.textContent = def.label || "?";
|
||||
}
|
||||
} else if (typeof vscIconSvgString === "function") {
|
||||
var svgStr = vscIconSvgString(btnId, 16);
|
||||
if (svgStr) {
|
||||
var iconSpan = vscCreateSvgWrap(document, svgStr, "vsc-btn-icon");
|
||||
if (iconSpan) {
|
||||
btn.appendChild(iconSpan);
|
||||
} else {
|
||||
btn.textContent = def.label || "?";
|
||||
}
|
||||
} else {
|
||||
btn.textContent = def.label || "?";
|
||||
}
|
||||
} else {
|
||||
btn.textContent = def.label || "?";
|
||||
}
|
||||
if (def.className) btn.className = def.className;
|
||||
btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1);
|
||||
btn.setAttribute("aria-label", btn.title);
|
||||
|
||||
btn.addEventListener("click", function () {
|
||||
if (btnId === "settings") {
|
||||
window.open(chrome.runtime.getURL("options/options.html"));
|
||||
return;
|
||||
}
|
||||
var message = { action: "run_action", actionName: btnId };
|
||||
if (shortcutTargetMode !== "all" && selectedFrameToken) {
|
||||
message.targetFrameToken = selectedFrameToken;
|
||||
}
|
||||
sendToActiveTab(
|
||||
message,
|
||||
function () {
|
||||
querySpeed();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
var manifest = chrome.runtime.getManifest();
|
||||
var versionElement = document.querySelector("#app-version");
|
||||
if (versionElement) {
|
||||
versionElement.textContent = manifest.version;
|
||||
}
|
||||
|
||||
document.querySelector("#config").addEventListener("click", function () {
|
||||
window.open(chrome.runtime.getURL("options/options.html"));
|
||||
});
|
||||
|
||||
document.querySelector("#about").addEventListener("click", function () {
|
||||
window.open("https://github.com/SoPat712/Speeder");
|
||||
});
|
||||
|
||||
document.querySelector("#feedback").addEventListener("click", function () {
|
||||
window.open("https://github.com/SoPat712/Speeder/issues");
|
||||
});
|
||||
|
||||
document.querySelector("#copyDiagnostics").addEventListener("click", function () {
|
||||
var report = buildDiagnosticReport();
|
||||
if (!report) {
|
||||
setStatusMessage("Diagnostics are still loading.");
|
||||
return;
|
||||
}
|
||||
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") {
|
||||
setStatusMessage("Clipboard access is unavailable.");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(report).then(
|
||||
function () {
|
||||
setStatusMessage("Diagnostics copied. Review before sharing.");
|
||||
},
|
||||
function () {
|
||||
setStatusMessage("Could not copy diagnostics.");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
document.querySelector("#addSiteRule").addEventListener("click", function () {
|
||||
var details = diagnosticContext
|
||||
? getCurrentSiteRuleDetails(diagnosticContext.url)
|
||||
: null;
|
||||
if (!details) {
|
||||
setStatusMessage("A site rule cannot be created for this page.");
|
||||
return;
|
||||
}
|
||||
var added = false;
|
||||
|
||||
updateStoredSettings(
|
||||
function (settings) {
|
||||
var rules = Array.isArray(settings.siteRules) ? settings.siteRules : [];
|
||||
if (
|
||||
rules.some(function(rule) {
|
||||
return rule && rule.pattern === details.pattern;
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
settings.siteRules = rules.concat([
|
||||
{
|
||||
title: details.title,
|
||||
pattern: details.pattern,
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
added = true;
|
||||
},
|
||||
function (error) {
|
||||
if (error) {
|
||||
setStatusMessage("Could not add this site: " + error.message);
|
||||
return;
|
||||
}
|
||||
setStatusMessage(
|
||||
added
|
||||
? "Site rule added. Opening settings..."
|
||||
: "Site rule already exists. Opening settings..."
|
||||
);
|
||||
window.open(chrome.runtime.getURL("options/options.html"));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
document.querySelector("#pauseTab").addEventListener("click", function () {
|
||||
var tab = diagnosticContext && diagnosticContext.tab;
|
||||
if (!tab || !Number.isInteger(tab.id)) {
|
||||
setStatusMessage("This page cannot be paused.");
|
||||
return;
|
||||
}
|
||||
var button = this;
|
||||
var paused = button.getAttribute("aria-pressed") !== "true";
|
||||
button.disabled = true;
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: "set_tab_paused", tabId: tab.id, paused: paused },
|
||||
function(response) {
|
||||
button.disabled = false;
|
||||
if (chrome.runtime.lastError || !response) {
|
||||
setStatusMessage("Could not update this tab.");
|
||||
return;
|
||||
}
|
||||
diagnosticContext.tabPaused = response.paused === true;
|
||||
updateTabPauseButton(diagnosticContext.tabPaused);
|
||||
setControlBarVisible(
|
||||
!diagnosticContext.tabPaused &&
|
||||
diagnosticContext.siteAvailable &&
|
||||
diagnosticContext.showBar
|
||||
);
|
||||
setForceButtonLoading(diagnosticContext.tabPaused);
|
||||
setStatusMessage(
|
||||
diagnosticContext.tabPaused
|
||||
? "Speeder is paused for this tab."
|
||||
: "Speeder resumed for this tab."
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
document.querySelector("#donate").addEventListener("click", function () {
|
||||
this.classList.add("hide");
|
||||
document.querySelector("#donateOptions").classList.remove("hide");
|
||||
});
|
||||
|
||||
document.querySelector("#enable").addEventListener("click", function () {
|
||||
toggleEnabled(true, settingsSavedReloadMessage);
|
||||
});
|
||||
|
||||
document.querySelector("#disable").addEventListener("click", function () {
|
||||
toggleEnabled(false, settingsSavedReloadMessage);
|
||||
});
|
||||
|
||||
document.querySelector("#refresh").addEventListener("click", function () {
|
||||
setStatusMessage("Rescanning page...");
|
||||
sendToActiveTab({ action: "rescan_page" }, function (response) {
|
||||
if (!response) {
|
||||
setStatusMessage("Cannot run on this page.");
|
||||
} else if (response.status === "complete") {
|
||||
setStatusMessage("Scan complete. Closing...");
|
||||
setTimeout(function () { window.close(); }, 500);
|
||||
} else {
|
||||
setStatusMessage("Scan failed. Please reload the page.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
selectedFrameToken = null;
|
||||
setForceButtonLoading(true);
|
||||
|
||||
chrome.storage.local.get(["customButtonIcons"], function (loc) {
|
||||
if (currentRenderToken !== renderToken) return;
|
||||
var customIconsMap =
|
||||
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
|
||||
? loc.customButtonIcons
|
||||
: {};
|
||||
|
||||
chrome.storage.sync.get(null, function (rawStorage) {
|
||||
if (currentRenderToken !== renderToken) return;
|
||||
var storage = vscExpandStoredSettings(rawStorage || {});
|
||||
|
||||
getActiveTabContext(function (context) {
|
||||
if (currentRenderToken !== renderToken) return;
|
||||
|
||||
var tabPaused = context && context.tabPaused === true;
|
||||
updateTabPauseButton(tabPaused);
|
||||
|
||||
var url = context && context.url ? context.url : "";
|
||||
var siteRule = matchSiteRule(url, storage.siteRules);
|
||||
var siteDisabled = isSiteRuleDisabled(siteRule);
|
||||
var siteAvailable = siteRuleUtils.isSpeederActiveForSite(
|
||||
storage.enabled,
|
||||
siteRule
|
||||
);
|
||||
shortcutTargetMode =
|
||||
siteRule && siteRule.shortcutTargetMode !== undefined
|
||||
? siteRule.shortcutTargetMode
|
||||
: storage.shortcutTargetMode;
|
||||
var showBar = storage.showPopupControlBar !== false;
|
||||
forceLastSavedSpeedControlledBySiteRule = Boolean(
|
||||
siteRule && siteRule.forceLastSavedSpeed !== undefined
|
||||
);
|
||||
var effectiveForceLastSavedSpeed =
|
||||
forceLastSavedSpeedControlledBySiteRule
|
||||
? siteRule.forceLastSavedSpeed === true
|
||||
: storage.forceLastSavedSpeed === true;
|
||||
diagnosticContext = {
|
||||
tab: context && context.tab,
|
||||
url: url,
|
||||
storage: storage,
|
||||
siteRule: siteRule,
|
||||
frame: null,
|
||||
tabPaused: tabPaused,
|
||||
siteAvailable: siteAvailable,
|
||||
showBar: showBar
|
||||
};
|
||||
document.querySelector("#addSiteRule").disabled =
|
||||
!getCurrentSiteRuleDetails(url);
|
||||
|
||||
if (siteRule && siteRule.showPopupControlBar !== undefined) {
|
||||
showBar = siteRule.showPopupControlBar;
|
||||
diagnosticContext.showBar = showBar;
|
||||
}
|
||||
|
||||
toggleEnabledUI(storage.enabled !== false);
|
||||
updateForceButton(effectiveForceLastSavedSpeed);
|
||||
buildControlBar(
|
||||
resolvePopupButtons(storage, siteRule),
|
||||
customIconsMap
|
||||
);
|
||||
setControlBarVisible(!tabPaused && siteAvailable && showBar);
|
||||
|
||||
if (tabPaused) {
|
||||
setForceButtonLoading(true);
|
||||
setStatusMessage("Speeder is paused for this tab.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (siteDisabled) {
|
||||
setForceButtonLoading(false);
|
||||
setStatusMessage("Speeder is disabled for this site.");
|
||||
updateSpeedDisplay(1);
|
||||
return;
|
||||
}
|
||||
|
||||
clearStatusMessage();
|
||||
if (siteAvailable) {
|
||||
querySpeed(function(frameContext) {
|
||||
if (currentRenderToken !== renderToken) return;
|
||||
diagnosticContext.frame = frameContext || null;
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
chrome.tabs.onActivated.addListener(function () {
|
||||
renderForActiveTab();
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {
|
||||
if (!tab || tab.active !== true) return;
|
||||
if (changeInfo.url !== undefined || changeInfo.status === "complete") {
|
||||
renderForActiveTab();
|
||||
}
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener(function (changes, areaName) {
|
||||
if (areaName === "local" && changes.customButtonIcons) {
|
||||
renderForActiveTab();
|
||||
return;
|
||||
}
|
||||
if (areaName !== "sync") return;
|
||||
if (
|
||||
changes.enabled ||
|
||||
changes.forceLastSavedSpeed ||
|
||||
changes.showPopupControlBar ||
|
||||
changes.controllerButtons ||
|
||||
changes.popupMatchHoverControls ||
|
||||
changes.popupControllerButtons ||
|
||||
changes.siteRules ||
|
||||
changes.siteRulesMeta ||
|
||||
changes.siteRulesFormat
|
||||
) {
|
||||
renderForActiveTab();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function toggleEnabledUI(enabled) {
|
||||
document.querySelector("#enable").classList.toggle("hide", enabled);
|
||||
document.querySelector("#disable").classList.toggle("hide", !enabled);
|
||||
}
|
||||
|
||||
function settingsSavedReloadMessage(enabled) {
|
||||
setStatusMessage(
|
||||
`${enabled ? "Enabled" : "Disabled"}. Open pages update automatically.`
|
||||
);
|
||||
}
|
||||
|
||||
function setStatusMessage(str) {
|
||||
const status_element = document.querySelector("#status");
|
||||
status_element.classList.toggle("hide", false);
|
||||
status_element.textContent = str;
|
||||
}
|
||||
|
||||
function clearStatusMessage() {
|
||||
const status_element = document.querySelector("#status");
|
||||
status_element.classList.toggle("hide", true);
|
||||
status_element.textContent = "";
|
||||
}
|
||||
});
|
||||
@@ -30,10 +30,26 @@
|
||||
"rememberSpeed",
|
||||
"showPopupControlBar",
|
||||
"siteRules",
|
||||
"siteRulesFormat",
|
||||
"siteRulesMeta",
|
||||
"speed",
|
||||
"startHidden",
|
||||
"subtitleNudgeAmount",
|
||||
"subtitleNudgeInterval"
|
||||
"subtitleNudgeEnabledByDefault",
|
||||
"subtitleNudgeInterval",
|
||||
"resetSpeed",
|
||||
"speedStep",
|
||||
"fastSpeed",
|
||||
"rewindTime",
|
||||
"advanceTime",
|
||||
"resetKeyCode",
|
||||
"slowerKeyCode",
|
||||
"fasterKeyCode",
|
||||
"rewindKeyCode",
|
||||
"advanceKeyCode",
|
||||
"fastKeyCode",
|
||||
"displayKeyCode",
|
||||
"blacklist"
|
||||
]);
|
||||
|
||||
function isRecognizedRawSettingsObject(backup) {
|
||||
@@ -46,6 +62,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
@@ -84,27 +119,64 @@
|
||||
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 (backup && backup.settings && typeof backup.settings === "object") {
|
||||
if (hasWrappedSettings) {
|
||||
if (
|
||||
!backup.settings ||
|
||||
typeof backup.settings !== "object" ||
|
||||
Array.isArray(backup.settings)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
settingsToImport = backup.settings;
|
||||
isWrappedBackup = true;
|
||||
} else if (
|
||||
backup &&
|
||||
typeof backup === "object" &&
|
||||
isRecognizedRawSettingsObject(backup)
|
||||
) {
|
||||
} else if (backupIsObject && 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"
|
||||
typeof backup.localSettings === "object" &&
|
||||
!Array.isArray(backup.localSettings)
|
||||
? backup.localSettings
|
||||
: null
|
||||
};
|
||||
@@ -117,6 +189,7 @@
|
||||
return {
|
||||
buildBackupPayload: buildBackupPayload,
|
||||
extractImportSettings: extractImportSettings,
|
||||
filterLocalSettingsForExport: filterLocalSettingsForExport,
|
||||
generateBackupFilename: generateBackupFilename,
|
||||
isRecognizedRawSettingsObject: isRecognizedRawSettingsObject,
|
||||
parseImportText: parseImportText
|
||||
@@ -8,6 +8,9 @@
|
||||
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 " ";
|
||||
@@ -112,11 +115,64 @@
|
||||
: 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
|
||||
normalizeBindingKey: normalizeBindingKey,
|
||||
sanitizeActionValue: sanitizeActionValue
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
(function(root, factory) {
|
||||
var exports = factory();
|
||||
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = exports;
|
||||
}
|
||||
|
||||
root.SpeederShared = root.SpeederShared || {};
|
||||
root.SpeederShared.popupControls = exports;
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
|
||||
function normalizeExcludedIds(excludedIds) {
|
||||
if (excludedIds instanceof Set) return excludedIds;
|
||||
return new Set(Array.isArray(excludedIds) ? excludedIds : []);
|
||||
}
|
||||
|
||||
function sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds) {
|
||||
if (!Array.isArray(buttonIds)) return [];
|
||||
|
||||
var seen = new Set();
|
||||
var excluded = normalizeExcludedIds(excludedIds);
|
||||
|
||||
return buttonIds.filter(function(id) {
|
||||
if (!controllerButtonDefs[id] || excluded.has(id) || seen.has(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePopupButtons(storage, siteRule, options) {
|
||||
var settings = storage || {};
|
||||
var config = options || {};
|
||||
var controllerButtonDefs = config.controllerButtonDefs || {};
|
||||
var defaultButtons = Array.isArray(config.defaultButtons)
|
||||
? config.defaultButtons
|
||||
: [];
|
||||
var excludedIds = config.excludedIds;
|
||||
|
||||
function sanitize(buttonIds) {
|
||||
return sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds);
|
||||
}
|
||||
|
||||
if (siteRule && Array.isArray(siteRule.popupControllerButtons)) {
|
||||
return sanitize(siteRule.popupControllerButtons);
|
||||
}
|
||||
|
||||
if (settings.popupMatchHoverControls) {
|
||||
if (siteRule && Array.isArray(siteRule.controllerButtons)) {
|
||||
return sanitize(siteRule.controllerButtons);
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.controllerButtons)) {
|
||||
return sanitize(settings.controllerButtons);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.popupControllerButtons)) {
|
||||
return sanitize(settings.popupControllerButtons);
|
||||
}
|
||||
|
||||
return sanitize(defaultButtons);
|
||||
}
|
||||
|
||||
function pickBestFrameSpeedResult(results) {
|
||||
if (!results || !results.length) return null;
|
||||
|
||||
function normalizeResult(result) {
|
||||
var normalized = { speed: result.speed };
|
||||
if (typeof result.frameToken === "string") {
|
||||
normalized.frameToken = result.frameToken;
|
||||
}
|
||||
if (result.diagnostics && typeof result.diagnostics === "object") {
|
||||
normalized.diagnostics = result.diagnostics;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getSafePageDetails(url) {
|
||||
try {
|
||||
var parsed = new URL(url);
|
||||
return {
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname || null
|
||||
};
|
||||
} catch (_error) {
|
||||
return { protocol: null, hostname: null };
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiagnosticReport(context) {
|
||||
var config = context || {};
|
||||
var storage = config.storage || {};
|
||||
var siteRule = config.siteRule || null;
|
||||
var frame = config.frame || null;
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
speederVersion: config.speederVersion || null,
|
||||
browser: config.browser || null,
|
||||
platform: config.platform || null,
|
||||
page: getSafePageDetails(config.url),
|
||||
globalSettings: {
|
||||
enabled: storage.enabled !== false,
|
||||
rememberSpeed: storage.rememberSpeed === true,
|
||||
forceLastSavedSpeed: storage.forceLastSavedSpeed === true,
|
||||
audioEnabled: storage.audioBoolean === true,
|
||||
startHidden: storage.startHidden === true,
|
||||
hideWithControls: storage.hideWithControls === true,
|
||||
controllerLocation: storage.controllerLocation,
|
||||
shortcutTargetMode: storage.shortcutTargetMode
|
||||
},
|
||||
matchedSiteRule: {
|
||||
matched: Boolean(siteRule),
|
||||
disabled: config.siteRuleDisabled === true,
|
||||
overrideKeys: siteRule
|
||||
? Object.keys(siteRule)
|
||||
.filter(function(key) {
|
||||
return key !== "pattern" && key !== "title";
|
||||
})
|
||||
.sort()
|
||||
: []
|
||||
},
|
||||
tabPaused: config.tabPaused === true,
|
||||
frame: frame && frame.diagnostics ? frame.diagnostics : null
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
buildDiagnosticReport: buildDiagnosticReport,
|
||||
pickBestFrameSpeedResult: pickBestFrameSpeedResult,
|
||||
resolvePopupButtons: resolvePopupButtons,
|
||||
sanitizeButtonOrder: sanitizeButtonOrder
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,1089 @@
|
||||
(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) {
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
@@ -0,0 +1,321 @@
|
||||
(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
|
||||
) {
|
||||
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
|
||||
};
|
||||
});
|
||||
@@ -16,6 +16,10 @@ 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"/>',
|
||||
@@ -72,7 +76,7 @@ function vscSanitizeSvgTree(svg) {
|
||||
n.remove();
|
||||
});
|
||||
|
||||
svg.querySelectorAll("*").forEach(function (el) {
|
||||
[svg].concat(Array.from(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();
|
||||
@@ -90,7 +94,7 @@ function vscSanitizeSvgTree(svg) {
|
||||
}
|
||||
});
|
||||
|
||||
svg.setAttribute("xmlns", VSC_SVG_NS);
|
||||
svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns", VSC_SVG_NS);
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
return svg;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/* Runs via chrome.tabs.executeScript(allFrames) in the same isolated world as inject.js */
|
||||
(function () {
|
||||
try {
|
||||
if (typeof getPrimaryVideoElement !== "function") {
|
||||
return null;
|
||||
}
|
||||
var v = getPrimaryVideoElement();
|
||||
if (!v) return null;
|
||||
return {
|
||||
speed: v.playbackRate,
|
||||
preferred: !v.paused
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
@@ -1,162 +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 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);
|
||||
}
|
||||
}
|
||||
@@ -1,2863 +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 || {};
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Speeder</title>
|
||||
<link rel="stylesheet" href="popup.css" />
|
||||
<script src="shared/site-rules.js"></script>
|
||||
<script src="shared/popup-controls.js"></script>
|
||||
<script src="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>
|
||||
@@ -1,380 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var speederShared =
|
||||
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
|
||||
var siteRuleUtils = speederShared.siteRules || {};
|
||||
var popupControlUtils = speederShared.popupControls || {};
|
||||
|
||||
/* `label` is only used if ui-icons.js has no path for this action (fallback). */
|
||||
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: "" },
|
||||
settings: { label: "", className: "" },
|
||||
pause: { label: "", className: "" },
|
||||
muted: { label: "", className: "" },
|
||||
mark: { 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;
|
||||
|
||||
function matchSiteRule(url, siteRules) {
|
||||
return siteRuleUtils.matchSiteRule(url, siteRules);
|
||||
}
|
||||
|
||||
function isSiteRuleDisabled(rule) {
|
||||
return siteRuleUtils.isSiteRuleDisabled(rule);
|
||||
}
|
||||
|
||||
function resolvePopupButtons(storage, siteRule) {
|
||||
return popupControlUtils.resolvePopupButtons(storage, siteRule, {
|
||||
controllerButtonDefs: controllerButtonDefs,
|
||||
defaultButtons: defaultButtons,
|
||||
excludedIds: popupExcludedButtonIds
|
||||
});
|
||||
}
|
||||
|
||||
function setControlBarVisible(visible) {
|
||||
var bar = document.getElementById("popupControlBar");
|
||||
var dividers = document.querySelectorAll(".popup-divider");
|
||||
if (bar) bar.style.display = visible ? "" : "none";
|
||||
dividers.forEach(function (d) { d.style.display = visible ? "" : "none"; });
|
||||
}
|
||||
|
||||
function sendToActiveTab(message, callback) {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
||||
if (tabs[0] && tabs[0].id) {
|
||||
chrome.tabs.sendMessage(tabs[0].id, message, function (response) {
|
||||
if (chrome.runtime.lastError) {
|
||||
if (callback) callback(null);
|
||||
} else {
|
||||
if (callback) callback(response);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (callback) callback(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveTabContext(callback) {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
||||
var activeTab = tabs && tabs[0] ? tabs[0] : null;
|
||||
if (!activeTab || !activeTab.id) {
|
||||
if (callback) callback({ tab: null, url: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
|
||||
if (tabUrl.length > 0) {
|
||||
if (callback) callback({ tab: activeTab, url: tabUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.tabs.sendMessage(
|
||||
activeTab.id,
|
||||
{ action: "get_page_context" },
|
||||
function (response) {
|
||||
if (chrome.runtime.lastError) {
|
||||
if (callback) callback({ tab: activeTab, url: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
var pageUrl =
|
||||
response && typeof response.url === "string" ? response.url : "";
|
||||
if (callback) callback({ tab: activeTab, url: pageUrl });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSpeedDisplay(speed) {
|
||||
var el = document.getElementById("popupSpeed");
|
||||
if (el) el.textContent = (speed != null ? Number(speed) : 1).toFixed(2);
|
||||
}
|
||||
|
||||
function applySpeedAndResetFromResponse(response) {
|
||||
if (response && response.speed != null) {
|
||||
updateSpeedDisplay(response.speed);
|
||||
}
|
||||
}
|
||||
|
||||
function pickBestFrameSpeedResult(results) {
|
||||
return popupControlUtils.pickBestFrameSpeedResult(results);
|
||||
}
|
||||
|
||||
function querySpeed() {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
|
||||
if (!tabs[0] || tabs[0].id == null) {
|
||||
return;
|
||||
}
|
||||
var tabId = tabs[0].id;
|
||||
chrome.tabs.executeScript(
|
||||
tabId,
|
||||
{ allFrames: true, file: "frameSpeedSnapshot.js" },
|
||||
function (results) {
|
||||
if (chrome.runtime.lastError) {
|
||||
sendToActiveTab({ action: "get_speed" }, function (response) {
|
||||
applySpeedAndResetFromResponse(response || { speed: 1 });
|
||||
});
|
||||
return;
|
||||
}
|
||||
var best = pickBestFrameSpeedResult(results);
|
||||
if (best) {
|
||||
applySpeedAndResetFromResponse(best);
|
||||
} else {
|
||||
sendToActiveTab({ action: "get_speed" }, function (response) {
|
||||
applySpeedAndResetFromResponse(response || { speed: 1 });
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buildControlBar(buttons, customIconsMap) {
|
||||
var bar = document.getElementById("popupControlBar");
|
||||
if (!bar) return;
|
||||
|
||||
var existing = bar.querySelectorAll("button");
|
||||
existing.forEach(function (btn) { btn.remove(); });
|
||||
|
||||
var customMap = customIconsMap || {};
|
||||
|
||||
buttons.forEach(function (btnId) {
|
||||
var def = controllerButtonDefs[btnId];
|
||||
if (!def) return;
|
||||
|
||||
var btn = document.createElement("button");
|
||||
btn.dataset.action = btnId;
|
||||
var customEntry = customMap[btnId];
|
||||
if (customEntry && customEntry.svg) {
|
||||
var customSpan = vscCreateSvgWrap(document, customEntry.svg, "vsc-btn-icon");
|
||||
if (customSpan) {
|
||||
btn.appendChild(customSpan);
|
||||
} else {
|
||||
btn.textContent = def.label || "?";
|
||||
}
|
||||
} else if (typeof vscIconSvgString === "function") {
|
||||
var svgStr = vscIconSvgString(btnId, 16);
|
||||
if (svgStr) {
|
||||
var iconSpan = vscCreateSvgWrap(document, svgStr, "vsc-btn-icon");
|
||||
if (iconSpan) {
|
||||
btn.appendChild(iconSpan);
|
||||
} else {
|
||||
btn.textContent = def.label || "?";
|
||||
}
|
||||
} else {
|
||||
btn.textContent = def.label || "?";
|
||||
}
|
||||
} else {
|
||||
btn.textContent = def.label || "?";
|
||||
}
|
||||
if (def.className) btn.className = def.className;
|
||||
btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1);
|
||||
|
||||
btn.addEventListener("click", function () {
|
||||
if (btnId === "settings") {
|
||||
window.open(chrome.runtime.getURL("options.html"));
|
||||
return;
|
||||
}
|
||||
sendToActiveTab(
|
||||
{ action: "run_action", actionName: btnId },
|
||||
function () {
|
||||
querySpeed();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
var manifest = chrome.runtime.getManifest();
|
||||
var versionElement = document.querySelector("#app-version");
|
||||
if (versionElement) {
|
||||
versionElement.innerText = manifest.version;
|
||||
}
|
||||
|
||||
document.querySelector("#config").addEventListener("click", function () {
|
||||
window.open(chrome.runtime.getURL("options.html"));
|
||||
});
|
||||
|
||||
document.querySelector("#about").addEventListener("click", function () {
|
||||
window.open("https://github.com/SoPat712/Speeder");
|
||||
});
|
||||
|
||||
document.querySelector("#feedback").addEventListener("click", function () {
|
||||
window.open("https://github.com/SoPat712/Speeder/issues");
|
||||
});
|
||||
|
||||
document.querySelector("#donate").addEventListener("click", function () {
|
||||
this.classList.add("hide");
|
||||
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);
|
||||
});
|
||||
|
||||
document.querySelector("#disable").addEventListener("click", function () {
|
||||
toggleEnabled(false, settingsSavedReloadMessage);
|
||||
});
|
||||
|
||||
document.querySelector("#refresh").addEventListener("click", function () {
|
||||
setStatusMessage("Rescanning page...");
|
||||
sendToActiveTab({ action: "rescan_page" }, function (response) {
|
||||
if (!response) {
|
||||
setStatusMessage("Cannot run on this page.");
|
||||
} else if (response.status === "complete") {
|
||||
setStatusMessage("Scan complete. Closing...");
|
||||
setTimeout(function () { window.close(); }, 500);
|
||||
} else {
|
||||
setStatusMessage("Scan failed. Please reload the page.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function renderForActiveTab() {
|
||||
var currentRenderToken = ++renderToken;
|
||||
|
||||
chrome.storage.local.get(["customButtonIcons"], function (loc) {
|
||||
if (currentRenderToken !== renderToken) return;
|
||||
var customIconsMap =
|
||||
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
|
||||
? loc.customButtonIcons
|
||||
: {};
|
||||
|
||||
chrome.storage.sync.get(storageDefaults, function (storage) {
|
||||
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 = storage.enabled !== false && !siteDisabled;
|
||||
var showBar = storage.showPopupControlBar !== false;
|
||||
|
||||
if (siteRule && siteRule.showPopupControlBar !== undefined) {
|
||||
showBar = siteRule.showPopupControlBar;
|
||||
}
|
||||
|
||||
toggleEnabledUI(storage.enabled !== false);
|
||||
buildControlBar(
|
||||
resolvePopupButtons(storage, siteRule),
|
||||
customIconsMap
|
||||
);
|
||||
setControlBarVisible(siteAvailable && showBar);
|
||||
|
||||
if (siteDisabled) {
|
||||
setStatusMessage("Speeder is disabled for this site.");
|
||||
updateSpeedDisplay(1);
|
||||
return;
|
||||
}
|
||||
|
||||
clearStatusMessage();
|
||||
if (siteAvailable) {
|
||||
querySpeed();
|
||||
} else {
|
||||
updateSpeedDisplay(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
chrome.tabs.onActivated.addListener(function () {
|
||||
renderForActiveTab();
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {
|
||||
if (!tab || tab.active !== true) return;
|
||||
if (changeInfo.url !== undefined || changeInfo.status === "complete") {
|
||||
renderForActiveTab();
|
||||
}
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener(function (changes, areaName) {
|
||||
if (areaName === "local" && changes.customButtonIcons) {
|
||||
renderForActiveTab();
|
||||
return;
|
||||
}
|
||||
if (areaName !== "sync") return;
|
||||
if (
|
||||
changes.enabled ||
|
||||
changes.showPopupControlBar ||
|
||||
changes.controllerButtons ||
|
||||
changes.popupMatchHoverControls ||
|
||||
changes.popupControllerButtons ||
|
||||
changes.siteRules
|
||||
) {
|
||||
renderForActiveTab();
|
||||
}
|
||||
});
|
||||
|
||||
renderForActiveTab();
|
||||
|
||||
function toggleEnabled(enabled, callback) {
|
||||
chrome.storage.sync.set({ enabled: enabled }, function () {
|
||||
toggleEnabledUI(enabled);
|
||||
if (callback) callback(enabled);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleEnabledUI(enabled) {
|
||||
document.querySelector("#enable").classList.toggle("hide", enabled);
|
||||
document.querySelector("#disable").classList.toggle("hide", !enabled);
|
||||
|
||||
const suffix = `${enabled ? "" : "_disabled"}.png`;
|
||||
chrome.browserAction.setIcon({
|
||||
path: {
|
||||
19: "icons/icon19" + suffix,
|
||||
38: "icons/icon38" + suffix,
|
||||
48: "icons/icon48" + suffix
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function settingsSavedReloadMessage(enabled) {
|
||||
setStatusMessage(
|
||||
`${enabled ? "Enabled" : "Disabled"}. Reload page to see changes`
|
||||
);
|
||||
}
|
||||
|
||||
function setStatusMessage(str) {
|
||||
const status_element = document.querySelector("#status");
|
||||
status_element.classList.toggle("hide", false);
|
||||
status_element.innerText = str;
|
||||
}
|
||||
|
||||
function clearStatusMessage() {
|
||||
const status_element = document.querySelector("#status");
|
||||
status_element.classList.toggle("hide", true);
|
||||
status_element.innerText = "";
|
||||
}
|
||||
});
|
||||
@@ -7,19 +7,20 @@ set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT"
|
||||
MANIFEST_PATH="extension/manifest.json"
|
||||
|
||||
manifest_version() {
|
||||
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
|
||||
MANIFEST_PATH="$MANIFEST_PATH" python3 -c 'import json, os; print(json.load(open(os.environ["MANIFEST_PATH"]))["version"])'
|
||||
}
|
||||
|
||||
bump_manifest() {
|
||||
local ver="$1"
|
||||
VER="$ver" python3 <<'PY'
|
||||
VER="$ver" MANIFEST_PATH="$MANIFEST_PATH" python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
ver = os.environ["VER"]
|
||||
path = "manifest.json"
|
||||
path = os.environ["MANIFEST_PATH"]
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data["version"] = ver
|
||||
@@ -58,8 +59,8 @@ fi
|
||||
git checkout beta
|
||||
git pull origin beta
|
||||
|
||||
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
|
||||
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
|
||||
SEMVER="$(normalize_semver "$SEMVER_IN")"
|
||||
validate_semver "$SEMVER"
|
||||
|
||||
@@ -73,7 +74,7 @@ fi
|
||||
echo
|
||||
echo "This will:"
|
||||
echo " 1. checkout main, merge --squash origin/beta (single release commit on main)"
|
||||
echo " 2. set manifest.json to $SEMVER in that commit (if anything else changed, it is included too)"
|
||||
echo " 2. set $MANIFEST_PATH 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
|
||||
|
||||
@@ -6,19 +6,20 @@ set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT"
|
||||
MANIFEST_PATH="extension/manifest.json"
|
||||
|
||||
manifest_version() {
|
||||
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
|
||||
MANIFEST_PATH="$MANIFEST_PATH" python3 -c 'import json, os; print(json.load(open(os.environ["MANIFEST_PATH"]))["version"])'
|
||||
}
|
||||
|
||||
bump_manifest() {
|
||||
local ver="$1"
|
||||
VER="$ver" python3 <<'PY'
|
||||
VER="$ver" MANIFEST_PATH="$MANIFEST_PATH" python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
ver = os.environ["VER"]
|
||||
path = "manifest.json"
|
||||
path = os.environ["MANIFEST_PATH"]
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data["version"] = ver
|
||||
@@ -57,8 +58,8 @@ fi
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
|
||||
echo "Current version in manifest.json: $(manifest_version)"
|
||||
read -r -p "New version for manifest.json (e.g. 5.0.4): " SEMVER_IN
|
||||
echo "Current version in $MANIFEST_PATH: $(manifest_version)"
|
||||
read -r -p "New version for $MANIFEST_PATH (e.g. 5.0.4): " SEMVER_IN
|
||||
SEMVER="$(normalize_semver "$SEMVER_IN")"
|
||||
validate_semver "$SEMVER"
|
||||
|
||||
@@ -76,7 +77,7 @@ fi
|
||||
|
||||
echo
|
||||
echo "This will:"
|
||||
echo " 1. set manifest.json version to $SEMVER, commit on dev, push origin dev"
|
||||
echo " 1. set $MANIFEST_PATH 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)"
|
||||
@@ -86,7 +87,7 @@ read -r -p "Continue? [y/N] " confirm
|
||||
echo "🚀 Releasing beta $TAG"
|
||||
|
||||
bump_manifest "$SEMVER"
|
||||
git add manifest.json
|
||||
git add "$MANIFEST_PATH"
|
||||
git commit -m "Bump version to $SEMVER"
|
||||
git push origin dev
|
||||
|
||||
|
||||
@@ -1,644 +0,0 @@
|
||||
(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,85 +0,0 @@
|
||||
(function(root, factory) {
|
||||
var exports = factory();
|
||||
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = exports;
|
||||
}
|
||||
|
||||
root.SpeederShared = root.SpeederShared || {};
|
||||
root.SpeederShared.popupControls = exports;
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
|
||||
function normalizeExcludedIds(excludedIds) {
|
||||
if (excludedIds instanceof Set) return excludedIds;
|
||||
return new Set(Array.isArray(excludedIds) ? excludedIds : []);
|
||||
}
|
||||
|
||||
function sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds) {
|
||||
if (!Array.isArray(buttonIds)) return [];
|
||||
|
||||
var seen = new Set();
|
||||
var excluded = normalizeExcludedIds(excludedIds);
|
||||
|
||||
return buttonIds.filter(function(id) {
|
||||
if (!controllerButtonDefs[id] || excluded.has(id) || seen.has(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePopupButtons(storage, siteRule, options) {
|
||||
var settings = storage || {};
|
||||
var config = options || {};
|
||||
var controllerButtonDefs = config.controllerButtonDefs || {};
|
||||
var defaultButtons = Array.isArray(config.defaultButtons)
|
||||
? config.defaultButtons
|
||||
: [];
|
||||
var excludedIds = config.excludedIds;
|
||||
|
||||
function sanitize(buttonIds) {
|
||||
return sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds);
|
||||
}
|
||||
|
||||
if (siteRule && Array.isArray(siteRule.popupControllerButtons)) {
|
||||
return sanitize(siteRule.popupControllerButtons);
|
||||
}
|
||||
|
||||
if (settings.popupMatchHoverControls) {
|
||||
if (siteRule && Array.isArray(siteRule.controllerButtons)) {
|
||||
return sanitize(siteRule.controllerButtons);
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.controllerButtons)) {
|
||||
return sanitize(settings.controllerButtons);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.popupControllerButtons)) {
|
||||
return sanitize(settings.popupControllerButtons);
|
||||
}
|
||||
|
||||
return sanitize(defaultButtons);
|
||||
}
|
||||
|
||||
function pickBestFrameSpeedResult(results) {
|
||||
if (!results || !results.length) return null;
|
||||
|
||||
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 { speed: result.speed };
|
||||
if (!fallback) fallback = { speed: result.speed };
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return {
|
||||
pickBestFrameSpeedResult: pickBestFrameSpeedResult,
|
||||
resolvePopupButtons: resolvePopupButtons,
|
||||
sanitizeButtonOrder: sanitizeButtonOrder
|
||||
};
|
||||
});
|
||||
@@ -1,69 +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, "\\$&");
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
loadHtmlString
|
||||
} = require("./helpers/extension-test-utils");
|
||||
|
||||
describe("background toolbar state", () => {
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
it("initializes and follows the enabled storage setting", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
const chrome = createChromeMock({ syncData: { enabled: false } });
|
||||
global.chrome = chrome;
|
||||
window.chrome = chrome;
|
||||
|
||||
evaluateScript("extension/background/background.js");
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenLastCalledWith({
|
||||
path: {
|
||||
19: "assets/icons/icon19_disabled.png",
|
||||
38: "assets/icons/icon38_disabled.png",
|
||||
48: "assets/icons/icon48_disabled.png"
|
||||
}
|
||||
});
|
||||
|
||||
chrome.storage.sync.set({ enabled: true });
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenLastCalledWith({
|
||||
path: {
|
||||
19: "assets/icons/icon19.png",
|
||||
38: "assets/icons/icon38.png",
|
||||
48: "assets/icons/icon48.png"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps tab pauses in memory and clears closed tabs", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
const chrome = createChromeMock();
|
||||
global.chrome = chrome;
|
||||
window.chrome = chrome;
|
||||
|
||||
evaluateScript("extension/background/background.js");
|
||||
const listener = chrome.runtime.onMessage.listeners[0];
|
||||
const respond = vi.fn();
|
||||
|
||||
listener(
|
||||
{ action: "set_tab_paused", tabId: 42, paused: true },
|
||||
{},
|
||||
respond
|
||||
);
|
||||
expect(respond).toHaveBeenLastCalledWith({ paused: true });
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
42,
|
||||
{ action: "set_tab_paused", paused: true },
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
listener({ action: "get_tab_pause_state" }, { tab: { id: 42 } }, respond);
|
||||
expect(respond).toHaveBeenLastCalledWith({ paused: true });
|
||||
|
||||
chrome.tabs.onRemoved.emit(42);
|
||||
listener({ action: "get_tab_pause_state", tabId: 42 }, {}, respond);
|
||||
expect(respond).toHaveBeenLastCalledWith({ paused: false });
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
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);
|
||||
@@ -11,19 +13,73 @@ 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) {
|
||||
document.open();
|
||||
document.write(readRepoFile(relPath));
|
||||
document.close();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function loadScript(relPath) {
|
||||
window.eval(
|
||||
const source =
|
||||
"var chrome = window.chrome || globalThis.chrome;\n" +
|
||||
readRepoFile(relPath) +
|
||||
"\n//# sourceURL=" +
|
||||
relPath
|
||||
);
|
||||
readRepoFile(relPath) +
|
||||
"\n//# sourceURL=" +
|
||||
relPath;
|
||||
const el = document.createElement("script");
|
||||
el.textContent = source;
|
||||
document.head.appendChild(el);
|
||||
mirrorExtensionGlobalsFromWindow(window);
|
||||
}
|
||||
|
||||
export async function flushAsyncWork() {
|
||||
@@ -117,7 +173,13 @@ export function createChromeMock(options = {}) {
|
||||
getManifest: vi.fn(() => ({
|
||||
version: options.manifestVersion || "9.9.9"
|
||||
})),
|
||||
getURL: vi.fn((url) => "moz-extension://speeder/" + url)
|
||||
getURL: vi.fn((url) => "moz-extension://speeder/" + url),
|
||||
sendMessage: vi.fn((message, callback) => {
|
||||
if (options.runtimeSendMessageImpl) {
|
||||
return options.runtimeSendMessageImpl(message, callback);
|
||||
}
|
||||
if (callback) callback({ paused: false });
|
||||
})
|
||||
},
|
||||
storage: {
|
||||
sync: syncArea,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { vi } = require("vitest");
|
||||
const { JSDOM } = require("jsdom");
|
||||
const vi = globalThis.vi;
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
@@ -18,22 +19,92 @@ function readWorkspaceFile(relPath) {
|
||||
}
|
||||
|
||||
function loadHtmlFile(relPath) {
|
||||
document.open();
|
||||
document.write(readWorkspaceFile(relPath));
|
||||
document.close();
|
||||
loadHtmlString(readWorkspaceFile(relPath));
|
||||
}
|
||||
|
||||
function loadHtmlString(html) {
|
||||
document.open();
|
||||
document.write(html);
|
||||
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();
|
||||
win.confirm = vi.fn(() => true);
|
||||
}
|
||||
|
||||
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 evaluateScript(relPath) {
|
||||
const source = readWorkspaceFile(relPath);
|
||||
window.eval(
|
||||
`${source}\n//# sourceURL=${workspacePath(relPath).replace(/\\/g, "/")}`
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
function fireDOMContentLoaded() {
|
||||
@@ -49,7 +120,11 @@ async function flushAsyncWork(turns) {
|
||||
const count = turns || 2;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (vi && typeof vi.isFakeTimers === "function" && vi.isFakeTimers()) {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +245,7 @@ function createChromeMock(options) {
|
||||
const storageOnChanged = createChromeEvent();
|
||||
const tabsOnActivated = createChromeEvent();
|
||||
const tabsOnUpdated = createChromeEvent();
|
||||
const tabsOnRemoved = createChromeEvent();
|
||||
const runtimeOnMessage = createChromeEvent();
|
||||
|
||||
const chrome = {
|
||||
@@ -177,6 +253,12 @@ function createChromeMock(options) {
|
||||
lastError: null,
|
||||
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
|
||||
getURL: vi.fn((relPath) => `moz-extension://${relPath}`),
|
||||
sendMessage: vi.fn((message, callback) => {
|
||||
if (config.runtimeSendMessageImpl) {
|
||||
return config.runtimeSendMessageImpl(message, callback);
|
||||
}
|
||||
if (callback) callback({ paused: false });
|
||||
}),
|
||||
onMessage: runtimeOnMessage
|
||||
},
|
||||
browserAction: {
|
||||
@@ -197,7 +279,8 @@ function createChromeMock(options) {
|
||||
}),
|
||||
create: vi.fn(),
|
||||
onActivated: tabsOnActivated,
|
||||
onUpdated: tabsOnUpdated
|
||||
onUpdated: tabsOnUpdated,
|
||||
onRemoved: tabsOnRemoved
|
||||
},
|
||||
storage: {
|
||||
onChanged: storageOnChanged,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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`; jsdom’s window keeps its own otherwise.
|
||||
win.Date = globalThis.Date;
|
||||
|
||||
win.open = vi.fn();
|
||||
win.close = vi.fn();
|
||||
win.confirm = vi.fn(() => true);
|
||||
}
|
||||
@@ -6,26 +6,52 @@ import {
|
||||
} from "./helpers/browser.js";
|
||||
|
||||
async function setupImportExport(overrides = {}) {
|
||||
loadHtml("options.html");
|
||||
loadHtml("extension/options/options.html");
|
||||
globalThis.chrome = createChromeMock(overrides);
|
||||
window.chrome = globalThis.chrome;
|
||||
globalThis.restore_options = vi.fn();
|
||||
loadScript("shared/import-export.js");
|
||||
loadScript("importExport.js");
|
||||
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");
|
||||
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 exportTime = new Date(2026, 3, 4, 8, 9, 10);
|
||||
vi.setSystemTime(exportTime);
|
||||
const chrome = await setupImportExport({
|
||||
sync: { rememberSpeed: true },
|
||||
sync: { rememberSpeed: true, shortcutTargetMode: "all" },
|
||||
local: { customButtonIcons: { faster: { slug: "rocket" } } }
|
||||
});
|
||||
const OriginalBlob = globalThis.Blob;
|
||||
globalThis.Blob = class TestBlob {
|
||||
const OriginalBlob = window.Blob;
|
||||
class TestBlob {
|
||||
constructor(parts, options) {
|
||||
this.parts = parts;
|
||||
this.options = options;
|
||||
@@ -34,24 +60,28 @@ describe("import/export flows", () => {
|
||||
async text() {
|
||||
return this.parts.join("");
|
||||
}
|
||||
};
|
||||
}
|
||||
globalThis.Blob = TestBlob;
|
||||
window.Blob = TestBlob;
|
||||
|
||||
let capturedBlob = null;
|
||||
let clickedDownload = null;
|
||||
Object.defineProperty(URL, "createObjectURL", {
|
||||
Object.defineProperty(window.URL, "createObjectURL", {
|
||||
configurable: true,
|
||||
value: vi.fn((blob) => {
|
||||
capturedBlob = blob;
|
||||
return "blob:test";
|
||||
})
|
||||
});
|
||||
Object.defineProperty(URL, "revokeObjectURL", {
|
||||
Object.defineProperty(window.URL, "revokeObjectURL", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => {})
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function () {
|
||||
clickedDownload = this.download;
|
||||
});
|
||||
vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation(
|
||||
function () {
|
||||
clickedDownload = this.download;
|
||||
}
|
||||
);
|
||||
|
||||
document.getElementById("exportSettings").click();
|
||||
|
||||
@@ -60,8 +90,17 @@ describe("import/export flows", () => {
|
||||
const blobText = await capturedBlob.text();
|
||||
expect(JSON.parse(blobText)).toEqual({
|
||||
version: "1.1",
|
||||
exportDate: "2026-04-04T12:09:10.000Z",
|
||||
settings: { rememberSpeed: true },
|
||||
exportDate: exportTime.toISOString(),
|
||||
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)" })
|
||||
]
|
||||
},
|
||||
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
|
||||
});
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
@@ -70,6 +109,55 @@ 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 () => {
|
||||
@@ -87,7 +175,7 @@ describe("import/export flows", () => {
|
||||
return el;
|
||||
});
|
||||
|
||||
globalThis.FileReader = class MockFileReader {
|
||||
class MockFileReader {
|
||||
readAsText(file) {
|
||||
this.onload({
|
||||
target: {
|
||||
@@ -95,7 +183,9 @@ describe("import/export flows", () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
globalThis.FileReader = MockFileReader;
|
||||
window.FileReader = MockFileReader;
|
||||
|
||||
globalThis.importSettings();
|
||||
createdInput.onchange({
|
||||
@@ -115,7 +205,7 @@ describe("import/export flows", () => {
|
||||
{ customButtonIcons: { faster: { slug: "rocket" } } },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(chrome.storage.sync.clear).toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.clear).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
||||
{ rememberSpeed: true },
|
||||
expect.any(Function)
|
||||
@@ -145,7 +235,7 @@ describe("import/export flows", () => {
|
||||
return el;
|
||||
});
|
||||
|
||||
globalThis.FileReader = class MockFileReader {
|
||||
class MockFileReader {
|
||||
readAsText(file) {
|
||||
this.onload({
|
||||
target: {
|
||||
@@ -153,7 +243,9 @@ describe("import/export flows", () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
globalThis.FileReader = MockFileReader;
|
||||
window.FileReader = MockFileReader;
|
||||
|
||||
globalThis.importSettings();
|
||||
createdInput.onchange({
|
||||
@@ -162,7 +254,13 @@ describe("import/export flows", () => {
|
||||
{
|
||||
__text: JSON.stringify({
|
||||
enabled: false,
|
||||
siteRules: [{ pattern: "example.com", enabled: false }]
|
||||
siteRules: [
|
||||
{
|
||||
title: "Example videos",
|
||||
pattern: "example.com",
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
]
|
||||
@@ -171,13 +269,17 @@ describe("import/export flows", () => {
|
||||
|
||||
expect(chrome.storage.local.clear).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
||||
{
|
||||
enabled: false,
|
||||
siteRules: [{ pattern: "example.com", enabled: false }]
|
||||
},
|
||||
expect.any(Function)
|
||||
const imported = globalThis.vscExpandStoredSettings(
|
||||
chrome.storage.sync.__state
|
||||
);
|
||||
expect(imported.enabled).toBe(false);
|
||||
expect(imported.siteRules).toEqual([
|
||||
{
|
||||
title: "Example videos",
|
||||
pattern: "example.com",
|
||||
enabled: false
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears stale local data when a wrapped backup has empty local settings", async () => {
|
||||
@@ -200,7 +302,7 @@ describe("import/export flows", () => {
|
||||
return el;
|
||||
});
|
||||
|
||||
globalThis.FileReader = class MockFileReader {
|
||||
class MockFileReader {
|
||||
readAsText(file) {
|
||||
this.onload({
|
||||
target: {
|
||||
@@ -208,7 +310,9 @@ describe("import/export flows", () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
globalThis.FileReader = MockFileReader;
|
||||
window.FileReader = MockFileReader;
|
||||
|
||||
globalThis.importSettings();
|
||||
createdInput.onchange({
|
||||
@@ -224,8 +328,15 @@ describe("import/export flows", () => {
|
||||
}
|
||||
});
|
||||
|
||||
expect(chrome.storage.local.clear).toHaveBeenCalled();
|
||||
expect(chrome.storage.local.clear).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.remove).toHaveBeenCalledWith(
|
||||
"customButtonIcons",
|
||||
expect.any(Function)
|
||||
);
|
||||
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)
|
||||
@@ -247,7 +358,7 @@ describe("import/export flows", () => {
|
||||
return el;
|
||||
});
|
||||
|
||||
globalThis.FileReader = class MockFileReader {
|
||||
class MockFileReader {
|
||||
readAsText(file) {
|
||||
this.onload({
|
||||
target: {
|
||||
@@ -255,7 +366,9 @@ describe("import/export flows", () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
globalThis.FileReader = MockFileReader;
|
||||
window.FileReader = MockFileReader;
|
||||
|
||||
globalThis.importSettings();
|
||||
createdInput.onchange({
|
||||
|
||||
@@ -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,18 +26,39 @@ 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();
|
||||
vi.stubGlobal("URL", {
|
||||
createObjectURL,
|
||||
revokeObjectURL
|
||||
Object.defineProperty(window.URL, "createObjectURL", {
|
||||
configurable: true,
|
||||
value: createObjectURL
|
||||
});
|
||||
Object.defineProperty(window.URL, "revokeObjectURL", {
|
||||
configurable: true,
|
||||
value: revokeObjectURL
|
||||
});
|
||||
global.URL = window.URL;
|
||||
|
||||
evaluateScript("importExport.js");
|
||||
evaluateScript("extension/shared/settings-core.js");
|
||||
evaluateScript("extension/shared/import-export.js");
|
||||
evaluateScript("extension/options/import-export.js");
|
||||
fireDOMContentLoaded();
|
||||
return { chrome, createObjectURL, revokeObjectURL };
|
||||
}
|
||||
|
||||
describe("importExport.js", () => {
|
||||
describe("options/import-export.js", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
@@ -49,18 +70,19 @@ describe("importExport.js", () => {
|
||||
});
|
||||
|
||||
it("generates timestamped backup filenames", () => {
|
||||
vi.setSystemTime(new Date("2026-04-04T13:14:15Z"));
|
||||
vi.setSystemTime(new Date(2026, 3, 4, 9, 14, 15));
|
||||
bootImportExport();
|
||||
|
||||
expect(window.generateBackupFilename()).toBe(
|
||||
"speeder-backup_2026-04-04_13.14.15.json"
|
||||
"speeder-backup_2026-04-04_09.14.15.json"
|
||||
);
|
||||
});
|
||||
|
||||
it("exports sync and local settings into a downloadable backup", async () => {
|
||||
const clickSpy = vi
|
||||
.spyOn(window.HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
Object.defineProperty(window.HTMLAnchorElement.prototype, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn()
|
||||
});
|
||||
const { createObjectURL, revokeObjectURL } = bootImportExport({
|
||||
syncData: {
|
||||
rememberSpeed: true,
|
||||
@@ -74,7 +96,6 @@ describe("importExport.js", () => {
|
||||
});
|
||||
|
||||
document.querySelector("#exportSettings").click();
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||
const blob = createObjectURL.mock.calls[0][0];
|
||||
@@ -82,14 +103,48 @@ describe("importExport.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("imports wrapped backups, restores local data, and refreshes the options page", async () => {
|
||||
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("reports partial success and refreshes when custom icons fail to import", async () => {
|
||||
const { chrome } = bootImportExport();
|
||||
window.restore_options = vi.fn();
|
||||
chrome.storage.local.set.mockImplementationOnce(function(_items, callback) {
|
||||
chrome.runtime.lastError = { message: "icon quota exceeded" };
|
||||
callback();
|
||||
chrome.runtime.lastError = null;
|
||||
});
|
||||
|
||||
const realCreateElement = document.createElement.bind(document);
|
||||
const fakeInput = realCreateElement("input");
|
||||
@@ -129,6 +184,7 @@ describe("importExport.js", () => {
|
||||
}
|
||||
|
||||
vi.stubGlobal("FileReader", FakeFileReader);
|
||||
window.FileReader = FakeFileReader;
|
||||
|
||||
document.querySelector("#importSettings").click();
|
||||
await flushAsyncWork();
|
||||
@@ -141,11 +197,17 @@ describe("importExport.js", () => {
|
||||
},
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(chrome.storage.sync.clear).toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.clear).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
||||
{ rememberSpeed: true, enabled: false },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(document.querySelector("#status").textContent).toContain(
|
||||
"Settings imported, but custom icons could not be updated"
|
||||
);
|
||||
expect(document.querySelector("#status").textContent).toContain(
|
||||
"icon quota exceeded"
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(window.restore_options).toHaveBeenCalled();
|
||||
@@ -178,6 +240,7 @@ describe("importExport.js", () => {
|
||||
}
|
||||
|
||||
vi.stubGlobal("FileReader", FakeFileReader);
|
||||
window.FileReader = FakeFileReader;
|
||||
|
||||
document.querySelector("#importSettings").click();
|
||||
await flushAsyncWork();
|
||||
@@ -186,4 +249,63 @@ describe("importExport.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>" }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,1263 @@
|
||||
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("ignores popup actions addressed to another frame", async () => {
|
||||
const chrome = bootInject();
|
||||
await settleLifecycle();
|
||||
const { video } = createControlledVideo();
|
||||
const listener = chrome.runtime.onMessage.listeners[0];
|
||||
const initialSpeed = video.playbackRate;
|
||||
|
||||
listener(
|
||||
{
|
||||
action: "run_action",
|
||||
actionName: "faster",
|
||||
targetFrameToken: "another-frame"
|
||||
},
|
||||
{},
|
||||
vi.fn()
|
||||
);
|
||||
|
||||
expect(video.playbackRate).toBe(initialSpeed);
|
||||
});
|
||||
|
||||
it("removes and restores controls when this tab is paused", async () => {
|
||||
const chrome = bootInject();
|
||||
await settleLifecycle();
|
||||
const { video } = createControlledVideo();
|
||||
const listener = chrome.runtime.onMessage.listeners[0];
|
||||
const playbackRate = video.playbackRate;
|
||||
|
||||
listener(
|
||||
{ action: "set_tab_paused", paused: true },
|
||||
{},
|
||||
vi.fn()
|
||||
);
|
||||
expect(window.tc.tabPaused).toBe(true);
|
||||
expect(video.vsc).toBeUndefined();
|
||||
expect(video.playbackRate).toBe(playbackRate);
|
||||
|
||||
listener(
|
||||
{ action: "set_tab_paused", paused: false },
|
||||
{},
|
||||
vi.fn()
|
||||
);
|
||||
expect(window.tc.tabPaused).toBe(false);
|
||||
expect(video.vsc).toBeTruthy();
|
||||
});
|
||||
|
||||
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("ignores shortcuts from selects and shadow-DOM edit fields", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
const { video } = createControlledVideo();
|
||||
const select = document.createElement("select");
|
||||
const host = document.createElement("site-editor");
|
||||
const input = document.createElement("input");
|
||||
host.attachShadow({ mode: "open" }).appendChild(input);
|
||||
document.body.append(select, host);
|
||||
|
||||
[select, input].forEach((target) => {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
code: "KeyD",
|
||||
key: "d"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(video.playbackRate).toBe(1);
|
||||
});
|
||||
|
||||
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
|
||||
};
|
||||
wrapper.showPopover = vi.fn();
|
||||
window.setupControllerHostTracking(controller, wrapper, normalMount);
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: fullscreenPlayer
|
||||
});
|
||||
|
||||
window.syncControllerFullscreenMount(controller);
|
||||
expect(fullscreenPlayer.contains(wrapper)).toBe(true);
|
||||
expect(wrapper.showPopover).not.toHaveBeenCalled();
|
||||
expect(wrapper.hasAttribute("popover")).toBe(false);
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: null
|
||||
});
|
||||
window.syncControllerFullscreenMount(controller);
|
||||
expect(wrapper.parentElement).toBe(normalMount);
|
||||
|
||||
wrapper.remove();
|
||||
controller.controllerHostCleanup();
|
||||
});
|
||||
|
||||
it("only promotes the directly-fullscreen video's controller", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const fullscreenPlayer = document.createElement("div");
|
||||
const fullscreenVideo = document.createElement("video");
|
||||
const otherVideo = document.createElement("video");
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
|
||||
fullscreenVideo.src = "https://example.org/fullscreen.mp4";
|
||||
otherVideo.src = "https://example.org/other.mp4";
|
||||
fullscreenPlayer.appendChild(fullscreenVideo);
|
||||
document.body.append(fullscreenPlayer, otherVideo);
|
||||
[fullscreenPlayer, fullscreenVideo, otherVideo].forEach((element) => {
|
||||
setRect(element, rect);
|
||||
setBoxMetrics(element, rect.width, rect.height);
|
||||
});
|
||||
window.ensureController(fullscreenVideo, fullscreenPlayer);
|
||||
window.ensureController(otherVideo, document.body);
|
||||
fullscreenVideo.vsc.div.showPopover = vi.fn();
|
||||
otherVideo.vsc.div.showPopover = vi.fn();
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: fullscreenVideo
|
||||
});
|
||||
window.syncControllerFullscreenMount(fullscreenVideo.vsc);
|
||||
window.syncControllerFullscreenMount(otherVideo.vsc);
|
||||
|
||||
expect(fullscreenVideo.vsc.div.showPopover).toHaveBeenCalledOnce();
|
||||
expect(otherVideo.vsc.div.showPopover).not.toHaveBeenCalled();
|
||||
expect(
|
||||
otherVideo.vsc.div.classList.contains("vsc-fullscreen-popover")
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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("creates named native buttons for in-player controls", async () => {
|
||||
bootInject({
|
||||
syncData: { controllerButtons: ["rewind", "nudge"] }
|
||||
});
|
||||
await settleLifecycle();
|
||||
const { wrapper } = createControlledVideo();
|
||||
const rewind = wrapper.shadowRoot.querySelector('[data-action="rewind"]');
|
||||
const nudge = wrapper.shadowRoot.querySelector("#nudge-indicator");
|
||||
|
||||
expect(rewind.tagName).toBe("BUTTON");
|
||||
expect(rewind.getAttribute("aria-label")).toBe("Rewind");
|
||||
expect(nudge.tagName).toBe("BUTTON");
|
||||
expect(nudge.getAttribute("aria-label")).toContain("Subtitle nudge");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
const { afterEach, describe, expect, it, vi } = require("vitest");
|
||||
const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
@@ -31,8 +30,11 @@ function bootInject(options) {
|
||||
);
|
||||
window.cancelIdleCallback = (id) => clearTimeout(id);
|
||||
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("inject.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;
|
||||
}
|
||||
@@ -116,14 +118,14 @@ describe("inject.js helper logic", () => {
|
||||
bootInject();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
window.tc.settings.siteRules = [{ pattern: "localhost", enabled: false }];
|
||||
window.tc.settings.siteRules = [{ pattern: "example.org", enabled: false }];
|
||||
window.captureSiteRuleBase();
|
||||
expect(window.applySiteRuleOverrides()).toBe(true);
|
||||
|
||||
window.resetSettingsFromSiteRuleBase();
|
||||
window.tc.settings.siteRules = [
|
||||
{
|
||||
pattern: "localhost",
|
||||
pattern: "example.org",
|
||||
controllerLocation: "bottom-left",
|
||||
controllerMarginTop: 300,
|
||||
controllerMarginBottom: -10,
|
||||
@@ -137,5 +139,250 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createChromeMock, flushAsyncWork, loadScript } from "./helpers/browser.js";
|
||||
import {
|
||||
createChromeMock,
|
||||
flushAsyncWork,
|
||||
loadHtmlString,
|
||||
loadScript
|
||||
} from "./helpers/browser.js";
|
||||
|
||||
function loadBlankDocument() {
|
||||
document.open();
|
||||
document.write("<!doctype html><html><body></body></html>");
|
||||
document.close();
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
}
|
||||
|
||||
async function bootInject({ sync = {}, local = {} } = {}) {
|
||||
@@ -35,11 +38,11 @@ async function bootInject({ sync = {}, local = {} } = {}) {
|
||||
);
|
||||
globalThis.cancelIdleCallback = (id) => clearTimeout(id);
|
||||
|
||||
loadScript("shared/controller-utils.js");
|
||||
loadScript("shared/key-bindings.js");
|
||||
loadScript("shared/site-rules.js");
|
||||
loadScript("ui-icons.js");
|
||||
loadScript("inject.js");
|
||||
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");
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await flushAsyncWork();
|
||||
@@ -48,6 +51,28 @@ 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: {
|
||||
@@ -87,4 +112,156 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const { afterEach, describe, expect, it } = require("vitest");
|
||||
const {
|
||||
evaluateScript,
|
||||
loadHtmlString
|
||||
@@ -11,8 +10,8 @@ describe("lucide-client.js", () => {
|
||||
|
||||
it("builds icon URLs and rejects invalid slugs", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("lucide-client.js");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/options/lucide-client.js");
|
||||
|
||||
expect(window.lucideIconSvgUrl("alarm-clock")).toContain(
|
||||
"/icons/alarm-clock.svg"
|
||||
@@ -23,8 +22,8 @@ describe("lucide-client.js", () => {
|
||||
|
||||
it("sanitizes SVG before persisting a Lucide icon", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("lucide-client.js");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/options/lucide-client.js");
|
||||
|
||||
const sanitized = window.sanitizeLucideSvg(`
|
||||
<svg width="10" height="10" onclick="evil()">
|
||||
@@ -43,8 +42,8 @@ describe("lucide-client.js", () => {
|
||||
|
||||
it("searches and ranks icon slugs by query", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("lucide-client.js");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/options/lucide-client.js");
|
||||
|
||||
const results = window.searchLucideSlugs(
|
||||
{
|
||||
|
||||
@@ -7,16 +7,17 @@ import {
|
||||
} from "./helpers/browser.js";
|
||||
|
||||
async function setupOptions(overrides = {}) {
|
||||
loadHtml("options.html");
|
||||
loadHtml("extension/options/options.html");
|
||||
globalThis.chrome = createChromeMock(overrides);
|
||||
window.chrome = globalThis.chrome;
|
||||
globalThis.fetch = vi.fn();
|
||||
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");
|
||||
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");
|
||||
triggerDomContentLoaded();
|
||||
await flushAsyncWork();
|
||||
return globalThis.chrome;
|
||||
@@ -28,7 +29,10 @@ 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: [
|
||||
@@ -37,8 +41,13 @@ 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"]
|
||||
}
|
||||
@@ -48,22 +57,92 @@ 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"]);
|
||||
});
|
||||
|
||||
it("reorders and toggles control-bar buttons from the keyboard", async () => {
|
||||
await setupOptions({
|
||||
sync: { controllerButtons: ["rewind", "faster"] }
|
||||
});
|
||||
const active = document.getElementById("controlBarActive");
|
||||
const rewind = active.querySelector('[data-button-id="rewind"]');
|
||||
|
||||
rewind.dispatchEvent(
|
||||
new window.KeyboardEvent("keydown", {
|
||||
key: "ArrowRight",
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
);
|
||||
expect(globalThis.getControlBarOrder()).toEqual(["faster", "rewind"]);
|
||||
|
||||
expect(rewind.disabled).toBe(false);
|
||||
rewind.click();
|
||||
expect(globalThis.getControlBarOrder()).toEqual(["faster"]);
|
||||
expect(rewind.closest(".cb-available-zone")).not.toBeNull();
|
||||
expect(rewind.getAttribute("aria-label")).toContain("available");
|
||||
});
|
||||
|
||||
it("labels shortcut and generated site-rule controls", async () => {
|
||||
await setupOptions();
|
||||
expect(document.getElementById("lucideIconResults").getAttribute("role")).toBe(
|
||||
"group"
|
||||
);
|
||||
expect(
|
||||
document.querySelector('#display .customKey').getAttribute("aria-label")
|
||||
).toBe("Show/hide controller key");
|
||||
|
||||
globalThis.createSiteRule(null);
|
||||
const rule = document.querySelector(".site-rule");
|
||||
const location = rule.querySelector(".site-controllerLocation");
|
||||
const locationLabel = location.closest(".site-rule-option").querySelector("label");
|
||||
expect(location.id).not.toBe("");
|
||||
expect(locationLabel.htmlFor).toBe(location.id);
|
||||
expect(
|
||||
rule.querySelector(".site-controllerMarginTop").getAttribute("aria-label")
|
||||
).toBe("Controller margin top");
|
||||
expect(
|
||||
rule.querySelector(".remove-site-rule").getAttribute("aria-label")
|
||||
).toBe("Remove site rule");
|
||||
});
|
||||
|
||||
it("validates site rule regexes before saving", async () => {
|
||||
const chrome = await setupOptions();
|
||||
chrome.storage.sync.set.mockClear();
|
||||
globalThis.createSiteRule(null);
|
||||
const rule = document.querySelector(".site-rule");
|
||||
rule.querySelector(".site-pattern").value = "/(/";
|
||||
rule.querySelector(".site-pattern").value = "/youtube";
|
||||
|
||||
globalThis.save_options();
|
||||
|
||||
@@ -73,6 +152,48 @@ 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: [] } });
|
||||
|
||||
@@ -96,6 +217,32 @@ 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: [] } });
|
||||
|
||||
@@ -128,11 +275,13 @@ 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;
|
||||
@@ -146,11 +295,20 @@ 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(
|
||||
@@ -164,31 +322,90 @@ describe("options page", () => {
|
||||
|
||||
globalThis.save_options();
|
||||
|
||||
expect(chrome.storage.sync.remove).toHaveBeenCalled();
|
||||
const savedSettings =
|
||||
chrome.storage.sync.set.mock.calls[
|
||||
chrome.storage.sync.set.mock.calls.length - 1
|
||||
][0];
|
||||
const savedSettings = globalThis.vscExpandStoredSettings(
|
||||
chrome.storage.sync.__state
|
||||
);
|
||||
|
||||
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.subtitleNudgeInterval).toBe(10);
|
||||
expect(savedSettings.subtitleNudgeEnabledByDefault).toBe(false);
|
||||
expect(savedSettings.subtitleNudgeInterval).toBe(250);
|
||||
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");
|
||||
});
|
||||
|
||||
it("leaves settings untouched when restoring defaults is cancelled", async () => {
|
||||
const chrome = await setupOptions({ sync: { rememberSpeed: true } });
|
||||
window.confirm.mockReturnValueOnce(false);
|
||||
chrome.storage.sync.set.mockClear();
|
||||
chrome.storage.sync.remove.mockClear();
|
||||
chrome.storage.local.set.mockClear();
|
||||
chrome.storage.local.remove.mockClear();
|
||||
|
||||
globalThis.restore_defaults();
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledOnce();
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.__state.rememberSpeed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
|
||||
const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
@@ -11,7 +10,7 @@ const {
|
||||
function bootOptions(options) {
|
||||
const config = options || {};
|
||||
|
||||
loadHtmlFile("options.html");
|
||||
loadHtmlFile("extension/options/options.html");
|
||||
installCommonWindowMocks();
|
||||
|
||||
const chrome = createChromeMock({
|
||||
@@ -31,9 +30,13 @@ function bootOptions(options) {
|
||||
);
|
||||
window.fetch = global.fetch;
|
||||
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("lucide-client.js");
|
||||
evaluateScript("options.js");
|
||||
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");
|
||||
fireDOMContentLoaded();
|
||||
|
||||
return chrome;
|
||||
@@ -47,6 +50,270 @@ 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("copies privacy-safe diagnostics from settings", async () => {
|
||||
bootOptions({
|
||||
syncData: {
|
||||
enabled: false,
|
||||
rememberSpeed: true,
|
||||
siteRules: [
|
||||
{
|
||||
title: "Private account",
|
||||
pattern: "secret.example/private-token",
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText }
|
||||
});
|
||||
await flushAsyncWork(3);
|
||||
|
||||
document.getElementById("copyDiagnostics").click();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
const reportText = writeText.mock.calls[0][0];
|
||||
const report = JSON.parse(reportText);
|
||||
expect(report.globalSettings.enabled).toBe(false);
|
||||
expect(report.globalSettings.rememberSpeed).toBe(true);
|
||||
expect(report.page).toEqual({ protocol: null, hostname: null });
|
||||
expect(report.matchedSiteRule.matched).toBe(false);
|
||||
expect(report.frame).toBeNull();
|
||||
expect(reportText).not.toContain("Private account");
|
||||
expect(reportText).not.toContain("private-token");
|
||||
expect(document.getElementById("status").textContent).toContain("copied");
|
||||
});
|
||||
|
||||
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: {
|
||||
@@ -119,7 +386,8 @@ describe("options.js", () => {
|
||||
window.populatePopupControlBarEditor(["advance", "settings", "rewind"]);
|
||||
|
||||
window.createSiteRule({ pattern: "youtube.com" });
|
||||
const ruleEl = document.querySelector(".site-rule");
|
||||
const siteRuleEls = document.querySelectorAll(".site-rule");
|
||||
const ruleEl = siteRuleEls[siteRuleEls.length - 1];
|
||||
ruleEl.querySelector(".override-placement").checked = true;
|
||||
ruleEl.querySelector(".site-controllerLocation").value = "top-right";
|
||||
ruleEl.querySelector(".site-controllerMarginTop").value = "300";
|
||||
@@ -142,44 +410,31 @@ describe("options.js", () => {
|
||||
|
||||
window.save_options();
|
||||
|
||||
expect(chrome.storage.sync.remove).toHaveBeenCalledWith(
|
||||
[
|
||||
"resetSpeed",
|
||||
"speedStep",
|
||||
"fastSpeed",
|
||||
"rewindTime",
|
||||
"advanceTime",
|
||||
"resetKeyCode",
|
||||
"slowerKeyCode",
|
||||
"fasterKeyCode",
|
||||
"rewindKeyCode",
|
||||
"advanceKeyCode",
|
||||
"fastKeyCode",
|
||||
"blacklist"
|
||||
],
|
||||
expect.any(Function)
|
||||
const savedSettings = window.vscExpandStoredSettings(
|
||||
chrome.storage.sync._dump()
|
||||
);
|
||||
|
||||
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).toEqual([
|
||||
{
|
||||
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).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"]
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks save when a site rule regex is invalid", async () => {
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
} from "./helpers/browser.js";
|
||||
|
||||
async function setupPopup(overrides = {}) {
|
||||
loadHtml("popup.html");
|
||||
loadHtml("extension/popup/popup.html");
|
||||
globalThis.chrome = createChromeMock(overrides);
|
||||
window.chrome = globalThis.chrome;
|
||||
loadScript("shared/site-rules.js");
|
||||
loadScript("shared/popup-controls.js");
|
||||
loadScript("ui-icons.js");
|
||||
loadScript("popup.js");
|
||||
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");
|
||||
triggerDomContentLoaded();
|
||||
await flushAsyncWork();
|
||||
return globalThis.chrome;
|
||||
@@ -29,11 +30,33 @@ describe("popup UI", () => {
|
||||
]
|
||||
});
|
||||
|
||||
expect(document.getElementById("app-version").innerText).toBe("5.1.7.0");
|
||||
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
|
||||
expect(document.getElementById("popupSpeed").textContent).toBe("1.75");
|
||||
expect(
|
||||
document.querySelectorAll("#popupControlBar button").length
|
||||
).toBeGreaterThan(0);
|
||||
expect(document.getElementById("status").getAttribute("role")).toBe(
|
||||
"status"
|
||||
);
|
||||
expect(
|
||||
document.querySelector("#popupControlBar button").getAttribute("aria-label")
|
||||
).not.toBe("");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -44,7 +67,7 @@ describe("popup UI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
expect(document.getElementById("status").innerText).toBe(
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
"Speeder is disabled for this site."
|
||||
);
|
||||
expect(document.getElementById("popupSpeed").textContent).toBe("1.00");
|
||||
@@ -53,7 +76,7 @@ describe("popup UI", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles enabled state and updates the browser action icons", async () => {
|
||||
it("toggles enabled state without owning background icon state", async () => {
|
||||
const chrome = await setupPopup();
|
||||
chrome.storage.sync.set.mockClear();
|
||||
chrome.browserAction.setIcon.mockClear();
|
||||
@@ -67,13 +90,10 @@ describe("popup UI", () => {
|
||||
expect(document.getElementById("enable").classList.contains("hide")).toBe(
|
||||
false
|
||||
);
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
|
||||
path: {
|
||||
19: "icons/icon19_disabled.png",
|
||||
38: "icons/icon38_disabled.png",
|
||||
48: "icons/icon48_disabled.png"
|
||||
}
|
||||
});
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
"Disabled. Open pages update automatically."
|
||||
);
|
||||
expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles refresh responses for unsupported and successful pages", async () => {
|
||||
@@ -89,13 +109,13 @@ describe("popup UI", () => {
|
||||
});
|
||||
|
||||
document.getElementById("refresh").click();
|
||||
expect(document.getElementById("status").innerText).toBe(
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
"Cannot run on this page."
|
||||
);
|
||||
|
||||
response = { status: "complete" };
|
||||
document.getElementById("refresh").click();
|
||||
expect(document.getElementById("status").innerText).toBe(
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
"Scan complete. Closing..."
|
||||
);
|
||||
vi.advanceTimersByTime(500);
|
||||
@@ -118,4 +138,62 @@ 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."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
|
||||
const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
@@ -7,17 +6,19 @@ const {
|
||||
installCommonWindowMocks,
|
||||
loadHtmlFile
|
||||
} = require("./helpers/extension-test-utils");
|
||||
const manifest = require("../extension/manifest.json");
|
||||
|
||||
function bootPopup(options) {
|
||||
const config = options || {};
|
||||
|
||||
loadHtmlFile("popup.html");
|
||||
loadHtmlFile("extension/popup/popup.html");
|
||||
installCommonWindowMocks();
|
||||
|
||||
const chrome = createChromeMock({
|
||||
manifest: { version: "9.9.9-test" },
|
||||
syncData: config.syncData,
|
||||
localData: config.localData,
|
||||
runtimeSendMessageImpl: config.runtimeSendMessageImpl,
|
||||
tabsQueryResult: [
|
||||
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
|
||||
]
|
||||
@@ -47,8 +48,11 @@ function bootPopup(options) {
|
||||
global.chrome = chrome;
|
||||
window.chrome = chrome;
|
||||
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("popup.js");
|
||||
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");
|
||||
fireDOMContentLoaded();
|
||||
|
||||
return chrome;
|
||||
@@ -61,6 +65,10 @@ 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: {
|
||||
@@ -90,25 +98,26 @@ 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 === 1
|
||||
? [
|
||||
{ speed: 1.25, preferred: false },
|
||||
{ speed: 1.5, frameToken: "playing-frame", 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 });
|
||||
@@ -117,7 +126,6 @@ describe("popup.js", () => {
|
||||
callback({ speed: 1.0 });
|
||||
});
|
||||
|
||||
document.dispatchEvent(new window.Event("DOMContentLoaded"));
|
||||
await flushAsyncWork();
|
||||
|
||||
const buttons = Array.from(
|
||||
@@ -131,15 +139,137 @@ describe("popup.js", () => {
|
||||
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
99,
|
||||
{ action: "run_action", actionName: "faster" },
|
||||
{
|
||||
action: "run_action",
|
||||
actionName: "faster",
|
||||
targetFrameToken: "playing-frame"
|
||||
},
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(document.querySelector("#popupSpeed").textContent).toBe("1.75");
|
||||
});
|
||||
|
||||
it("toggles enablement and closes after a successful refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
it("keeps all-video popup actions intentionally untargeted", async () => {
|
||||
const chrome = bootPopup({
|
||||
syncData: { shortcutTargetMode: "all" },
|
||||
executeScriptImpl: (tabId, details, callback) => {
|
||||
callback([{ speed: 1.5, frameToken: "playing-frame", preferred: true }]);
|
||||
}
|
||||
});
|
||||
await flushAsyncWork();
|
||||
chrome.tabs.sendMessage.mockClear();
|
||||
|
||||
document.querySelector("#popupControlBar button").click();
|
||||
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
99,
|
||||
{ action: "run_action", actionName: "rewind" },
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("copies redacted diagnostics for the active media frame", async () => {
|
||||
bootPopup({
|
||||
activeTab: {
|
||||
id: 12,
|
||||
active: true,
|
||||
url: "https://video.example/watch?private_token=secret"
|
||||
},
|
||||
executeScriptImpl: (tabId, details, callback) => {
|
||||
callback([
|
||||
{
|
||||
speed: 1.5,
|
||||
preferred: true,
|
||||
diagnostics: {
|
||||
mediaType: "video",
|
||||
fullscreen: { active: true, element: "div", ownsMedia: true },
|
||||
controller: { present: true, hidden: false }
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
});
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText }
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
document.querySelector("#copyDiagnostics").click();
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(writeText).toHaveBeenCalled();
|
||||
const reportText = writeText.mock.calls.at(-1)[0];
|
||||
const report = JSON.parse(reportText);
|
||||
expect(report.page).toEqual({
|
||||
protocol: "https:",
|
||||
hostname: "video.example"
|
||||
});
|
||||
expect(report.frame.mediaType).toBe("video");
|
||||
expect(reportText).not.toContain("private_token");
|
||||
expect(reportText).not.toContain("secret");
|
||||
expect(document.querySelector("#status").textContent).toContain("copied");
|
||||
});
|
||||
|
||||
it("adds one safe origin rule for the current site", async () => {
|
||||
const chrome = bootPopup({
|
||||
activeTab: {
|
||||
id: 18,
|
||||
active: true,
|
||||
url: "https://courses.example:8443/watch/lesson?token=private"
|
||||
}
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
document.querySelector("#addSiteRule").click();
|
||||
await flushAsyncWork();
|
||||
document.querySelector("#addSiteRule").click();
|
||||
await flushAsyncWork();
|
||||
|
||||
const rules = window.vscExpandStoredSettings(
|
||||
chrome.storage.sync._dump()
|
||||
).siteRules.filter(function(rule) {
|
||||
return rule.pattern === "https://courses.example:8443";
|
||||
});
|
||||
expect(rules).toEqual([
|
||||
{
|
||||
title: "courses.example:8443",
|
||||
pattern: "https://courses.example:8443",
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
"moz-extension://options/options.html"
|
||||
);
|
||||
});
|
||||
|
||||
it("pauses only the active tab for the browser session", async () => {
|
||||
let paused = false;
|
||||
const chrome = bootPopup({
|
||||
runtimeSendMessageImpl: (message, callback) => {
|
||||
if (message.action === "set_tab_paused") paused = message.paused === true;
|
||||
callback({ paused });
|
||||
}
|
||||
});
|
||||
await flushAsyncWork();
|
||||
|
||||
document.querySelector("#pauseTab").click();
|
||||
|
||||
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
|
||||
{ action: "set_tab_paused", tabId: 99, paused: true },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(document.querySelector("#pauseTab").textContent).toBe(
|
||||
"Resume on this tab"
|
||||
);
|
||||
expect(document.querySelector("#popupControlBar").style.display).toBe(
|
||||
"none"
|
||||
);
|
||||
expect(document.querySelector("#status").textContent).toContain("paused");
|
||||
});
|
||||
|
||||
it("toggles enablement and closes after a successful refresh", async () => {
|
||||
const chrome = bootPopup({
|
||||
syncData: {
|
||||
enabled: false
|
||||
@@ -147,22 +277,17 @@ 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.set).toHaveBeenCalledWith(
|
||||
{ enabled: true },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
|
||||
path: {
|
||||
19: "icons/icon19.png",
|
||||
38: "icons/icon38.png",
|
||||
48: "icons/icon48.png"
|
||||
}
|
||||
});
|
||||
expect(chrome.storage.sync._dump()).not.toHaveProperty("enabled");
|
||||
expect(
|
||||
window.vscExpandStoredSettings(chrome.storage.sync._dump()).enabled
|
||||
).toBe(true);
|
||||
expect(chrome.browserAction.setIcon).not.toHaveBeenCalled();
|
||||
|
||||
document.querySelector("#refresh").click();
|
||||
expect(document.querySelector("#status").textContent).toContain("Closing");
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,11 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete globalThis.SpeederShared;
|
||||
delete globalThis.restore_options;
|
||||
try {
|
||||
delete globalThis.restore_options;
|
||||
} catch {
|
||||
globalThis.restore_options = undefined;
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
document.head.innerHTML = "";
|
||||
document.body.innerHTML = "";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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";
|
||||
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";
|
||||
|
||||
describe("shared helpers", () => {
|
||||
it("matches site rules and skips invalid regex patterns", () => {
|
||||
@@ -22,6 +22,74 @@ describe("shared helpers", () => {
|
||||
])
|
||||
).toBeNull();
|
||||
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
|
||||
expect(siteRules.compileSiteRulePattern("/youtube")).toBeNull();
|
||||
expect(
|
||||
siteRules.siteRuleMatchesUrl(
|
||||
{ pattern: "/youtube" },
|
||||
"https://youtube.com/watch"
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -73,6 +141,15 @@ describe("shared helpers", () => {
|
||||
).toEqual(["advance"]);
|
||||
});
|
||||
|
||||
it("keeps the selected frame token with the displayed popup speed", () => {
|
||||
expect(
|
||||
popupControls.pickBestFrameSpeedResult([
|
||||
{ speed: 1.25, frameToken: "background", preferred: false },
|
||||
{ speed: 1.75, frameToken: "playing", preferred: true }
|
||||
])
|
||||
).toEqual({ speed: 1.75, frameToken: "playing" });
|
||||
});
|
||||
|
||||
it("normalizes controller locations and margins", () => {
|
||||
expect(controllerUtils.normalizeControllerLocation("top-right")).toBe(
|
||||
"top-right"
|
||||
@@ -97,6 +174,14 @@ 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", () => {
|
||||
@@ -138,6 +223,16 @@ describe("shared helpers", () => {
|
||||
localSettings: null
|
||||
});
|
||||
|
||||
expect(
|
||||
importExportUtils.extractImportSettings({
|
||||
subtitleNudgeEnabledByDefault: false
|
||||
})
|
||||
).toEqual({
|
||||
isWrappedBackup: false,
|
||||
settings: { subtitleNudgeEnabledByDefault: false },
|
||||
localSettings: null
|
||||
});
|
||||
|
||||
expect(
|
||||
importExportUtils.extractImportSettings({ enabled: true })
|
||||
).toEqual({
|
||||
@@ -149,5 +244,39 @@ 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" } }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ module.exports = defineConfig({
|
||||
clearMocks: true,
|
||||
globals: true,
|
||||
restoreMocks: true,
|
||||
include: ["tests/**/*.test.js"],
|
||||
include: ["tests/**/*.test.js", "tests/**/*.spec.js"],
|
||||
setupFiles: ["./tests/setup.js"]
|
||||
}
|
||||
});
|
||||
|
||||