Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a7dc3097e
|
||
|
|
c626aca89c
|
||
|
|
05b8456e94
|
||
|
|
0cbfac4b82
|
||
|
|
b3707c0803
|
||
|
|
fb25c56230
|
||
|
|
4efc3e0acc
|
||
|
|
7c0a188cd3
|
||
|
|
d7ce1fd000
|
||
|
|
313832015b
|
@@ -10,6 +10,8 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
WEB_EXT_IGNORE_FILES: scripts/**
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -18,7 +20,7 @@ jobs:
|
||||
run: npm install -g web-ext
|
||||
|
||||
- name: Lint
|
||||
run: web-ext lint --source-dir extension
|
||||
run: web-ext lint
|
||||
|
||||
# Beta tag (v*-beta) → Sign as unlisted on AMO, attach signed XPI to GitHub Prerelease
|
||||
# Firefox blocks all unsigned XPIs, even for self-hosted installs — unlisted signing is required
|
||||
@@ -28,7 +30,7 @@ jobs:
|
||||
web-ext sign \
|
||||
--api-key ${{ secrets.FIREFOX_API_KEY }} \
|
||||
--api-secret ${{ secrets.FIREFOX_API_SECRET }} \
|
||||
--source-dir extension \
|
||||
--source-dir . \
|
||||
--artifacts-dir web-ext-artifacts \
|
||||
--channel unlisted
|
||||
|
||||
@@ -68,6 +70,6 @@ jobs:
|
||||
web-ext sign \
|
||||
--api-key ${{ secrets.FIREFOX_API_KEY }} \
|
||||
--api-secret ${{ secrets.FIREFOX_API_SECRET }} \
|
||||
--source-dir extension \
|
||||
--source-dir . \
|
||||
--artifacts-dir web-ext-artifacts \
|
||||
--channel listed
|
||||
|
||||
@@ -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,11 +33,9 @@ last point to listen to it a few more times.
|
||||
|
||||

|
||||
|
||||
## Using the extension
|
||||
#### *Install [Chrome](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk) or [Firefox](https://addons.mozilla.org/en-us/firefox/addon/speeder/) Extension*
|
||||
|
||||
[](https://addons.mozilla.org/firefox/addon/speeder/)
|
||||
|
||||
Once the extension is installed simply navigate to any page that offers
|
||||
\*\* Once the extension is installed simply navigate to any page that offers
|
||||
HTML5 video ([example](https://www.youtube.com/watch?v=E9FxNzv1Tr8)), and you'll
|
||||
see a speed indicator in top left corner. Hover over the indicator to reveal the
|
||||
controls to accelerate, slowdown, and quickly rewind or advance the video. Or,
|
||||
@@ -67,35 +65,21 @@ listens both for lower and upper case values (i.e. you can use
|
||||
key. This is not a perfect solution, as some sites may listen to both, but works
|
||||
most of the time.
|
||||
|
||||
## Development
|
||||
### FAQ
|
||||
|
||||
The unpacked extension root is `extension/`. Load that directory in
|
||||
`about:debugging`, and run extension tooling against it, for example:
|
||||
|
||||
```sh
|
||||
npm test
|
||||
npx --yes web-ext lint --source-dir extension
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### The video controls are not showing up?
|
||||
|
||||
This extension is only compatible
|
||||
**The video controls are not showing up?** This extension is only compatible
|
||||
with HTML5 video. If you don't see the controls showing up, chances are you are
|
||||
viewing a Flash video. If you want to confirm, try right-clicking on the video
|
||||
and inspect the menu: if it mentions flash, then that's the issue. That said,
|
||||
most sites will fallback to HTML5 if they detect that Flash is not available.
|
||||
You can try manually disabling Flash from the browser.
|
||||
|
||||
### What is this fork all about?
|
||||
|
||||
This is a fork of
|
||||
[CodeBicycle's Video Speed Controller extension for Firefox](https://github.com/codebicycle/videospeed)
|
||||
**What is this fork all about?** This is a fork of
|
||||
[CodeBicycle's Video Speed Controller extension for Firefox](https://github.com/codebicycle/videospeed)
|
||||
which is a fork of [Igrigorik's Video Speed Controller extension for Chromium](https://github.com/igrigorik/videospeed).
|
||||
|
||||
The goal of this fork is fix bugs in the upstream code as well as add new features.
|
||||
|
||||
## License
|
||||
### License
|
||||
|
||||
(GPLv3) - Copyright (c) 2025 Josh Patra
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
chrome.runtime.onMessage.addListener(function (request) {
|
||||
if (request.action === "openOptions") {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL("options/options.html") });
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL("options.html") });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import glob
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
SCRIPT_NAME = os.path.basename(__file__)
|
||||
TARGET_FILE = "manifest.json"
|
||||
DEFAULT_EXCLUDE_FILES = {".DS_Store"}
|
||||
DEFAULT_EXCLUDE_DIRS = {"__pycache__", "temp"}
|
||||
DEFAULT_EXCLUDE_PATTERNS = {"._*", "*.pyc"}
|
||||
|
||||
|
||||
def should_exclude(rel_path, exclude_files, exclude_dirs):
|
||||
rel_path = os.path.normpath(rel_path)
|
||||
path_parts = rel_path.split(os.sep)
|
||||
file_name = path_parts[-1]
|
||||
|
||||
if file_name in DEFAULT_EXCLUDE_FILES or rel_path in DEFAULT_EXCLUDE_FILES:
|
||||
return True
|
||||
|
||||
if any(part in DEFAULT_EXCLUDE_DIRS for part in path_parts):
|
||||
return True
|
||||
|
||||
if file_name in exclude_files or rel_path in exclude_files:
|
||||
return True
|
||||
|
||||
if any(part in exclude_dirs for part in path_parts):
|
||||
return True
|
||||
|
||||
if any(fnmatch.fnmatch(file_name, pattern) for pattern in DEFAULT_EXCLUDE_PATTERNS):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def zip_folder(output_name, folder, exclude_files, exclude_dirs):
|
||||
with zipfile.ZipFile(output_name, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for root, dirs, files in os.walk(folder):
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if not should_exclude(
|
||||
os.path.relpath(os.path.join(root, d), folder),
|
||||
exclude_files,
|
||||
exclude_dirs,
|
||||
)
|
||||
]
|
||||
for file in files:
|
||||
rel_path = os.path.relpath(os.path.join(root, file), folder)
|
||||
if should_exclude(rel_path, exclude_files, exclude_dirs):
|
||||
continue
|
||||
zipf.write(os.path.join(root, file), arcname=rel_path)
|
||||
|
||||
|
||||
def update_version_line(file_path, new_version):
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
updated = False
|
||||
for i, line in enumerate(lines):
|
||||
match = re.match(r'\s*"version":\s*"([^"]+)"', line)
|
||||
if match:
|
||||
old_version = match.group(1)
|
||||
lines[i] = re.sub(
|
||||
r'"version":\s*".+?"', f'"version": "{new_version}"', line
|
||||
)
|
||||
updated = True
|
||||
print(
|
||||
f"🛠️ Changed version in {file_path} from {old_version} ➜ {new_version}"
|
||||
)
|
||||
break
|
||||
|
||||
if updated:
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
else:
|
||||
print(f"⚠️ No version line found in {file_path}.")
|
||||
|
||||
|
||||
def main():
|
||||
# Step 0: Remove all existing .xpi files upfront
|
||||
xpi_files = glob.glob("*.xpi")
|
||||
for f in xpi_files:
|
||||
try:
|
||||
os.remove(f)
|
||||
print(f"🗑️ Removed existing archive: {f}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to remove {f}: {e}")
|
||||
|
||||
# Read current version from manifest.json
|
||||
current_dir = os.getcwd()
|
||||
manifest_path = os.path.join(current_dir, TARGET_FILE)
|
||||
current_version = "unknown"
|
||||
|
||||
if os.path.exists(manifest_path):
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
match = re.match(r'\s*"version":\s*"([^"]+)"', line)
|
||||
if match:
|
||||
current_version = match.group(1)
|
||||
break
|
||||
|
||||
print(f"📦 Current version: {current_version}")
|
||||
base_version = input("Enter the new base version (e.g., 2.0.1): ").strip()
|
||||
if not base_version:
|
||||
print("❌ No version entered. Exiting.")
|
||||
return
|
||||
|
||||
github_version = f"{base_version}.0"
|
||||
|
||||
# Step 1: Update manifest.json on disk to base_version (for Firefox)
|
||||
if os.path.exists(manifest_path):
|
||||
update_version_line(manifest_path, base_version)
|
||||
else:
|
||||
print(f"❌ {TARGET_FILE} not found. Aborting.")
|
||||
return
|
||||
|
||||
# Step 2: Create videospeed-firefox.xpi (exclude script, .git, AND videospeed-firefox.xpi itself)
|
||||
exclude_files = [SCRIPT_NAME, "videospeed-firefox.xpi"]
|
||||
exclude_dirs = [".git"]
|
||||
zip_folder("videospeed-firefox.xpi", current_dir, exclude_files, exclude_dirs)
|
||||
print("✅ Created videospeed-firefox.xpi")
|
||||
|
||||
# Step 3: Re-scan for .xpi files after Firefox archive creation, exclude them for GitHub zip
|
||||
current_xpi_files = set(glob.glob("*.xpi"))
|
||||
exclude_temp_files = current_xpi_files.union({SCRIPT_NAME})
|
||||
exclude_temp_dirs = set(exclude_dirs)
|
||||
|
||||
# Step 4: Create videospeed-github.xpi from temp folder with version bumped to .0
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
for item in os.listdir(current_dir):
|
||||
if should_exclude(item, exclude_temp_files, exclude_temp_dirs):
|
||||
continue
|
||||
src = os.path.join(current_dir, item)
|
||||
dst = os.path.join(temp_dir, item)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, dst)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
temp_manifest = os.path.join(temp_dir, TARGET_FILE)
|
||||
if os.path.exists(temp_manifest):
|
||||
update_version_line(temp_manifest, github_version)
|
||||
else:
|
||||
print(f"⚠️ {TARGET_FILE} not found in temp folder.")
|
||||
|
||||
zip_folder(
|
||||
"videospeed-github.xpi", temp_dir, exclude_files=[], exclude_dirs=[]
|
||||
)
|
||||
print("✅ Created videospeed-github.xpi")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,110 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Speeder</title>
|
||||
<link rel="stylesheet" href="popup.css" />
|
||||
<script src="../shared/site-rules.js"></script>
|
||||
<script src="../shared/popup-controls.js"></script>
|
||||
<script src="../shared/ui-icons.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup-shell">
|
||||
<div class="popup-header">
|
||||
<span class="popup-title">Speeder</span>
|
||||
<span class="popup-version">v<span id="app-version"></span></span>
|
||||
</div>
|
||||
<div class="popup-actions">
|
||||
<button id="refresh">Rescan page for videos</button>
|
||||
<button
|
||||
id="forceLastSavedSpeed"
|
||||
class="popup-compact-action"
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
>Force last saved speed</button>
|
||||
<div class="popup-divider"></div>
|
||||
<div id="popupControlBar" class="popup-control-bar">
|
||||
<span id="popupSpeed" class="popup-speed">1.00</span>
|
||||
</div>
|
||||
<div class="popup-divider"></div>
|
||||
<button id="enable" class="hide">Enable</button>
|
||||
<button id="disable">Disable</button>
|
||||
</div>
|
||||
<div id="status" class="popup-status hide"></div>
|
||||
<div class="popup-links">
|
||||
<button id="config">Settings</button>
|
||||
<div class="popup-secondary">
|
||||
<button id="feedback" class="secondary">Feedback</button>
|
||||
<button id="about" class="secondary">About</button>
|
||||
</div>
|
||||
<div id="donateWrap" class="donate-wrap">
|
||||
<button id="donate" class="secondary">Donate</button>
|
||||
<div id="donateOptions" class="donate-split hide">
|
||||
<a
|
||||
id="donateGithub"
|
||||
class="donate-icon-btn donate-icon-btn--github"
|
||||
href="https://github.com/sponsors/SoPat712"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Sponsor on GitHub (opens in new tab)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
id="donateKofi"
|
||||
class="donate-icon-btn donate-icon-btn--kofi"
|
||||
href="https://ko-fi.com/joshpatra"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Support on Ko-fi (opens in new tab)"
|
||||
>
|
||||
<img
|
||||
src="../assets/images/kofi_symbol.svg"
|
||||
width="28"
|
||||
height="22"
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
id="donateBmc"
|
||||
class="donate-icon-btn donate-icon-btn--bmc"
|
||||
href="https://buymeacoffee.com/treeman183"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Support on Buy Me a Coffee (opens in new tab)"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="22"
|
||||
height="22"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -6,12 +6,16 @@
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
pointer-events: none !important;
|
||||
/* Keep the interactive controller above player-owned click/pause panes. */
|
||||
z-index: 2147483647 !important;
|
||||
z-index: 2147483646 !important;
|
||||
white-space: normal;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Use minimal z-index for non-YouTube sites to avoid overlapping modals */
|
||||
.vsc-controller.vsc-non-youtube {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.vsc-nosource {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -39,7 +39,6 @@ var tc = {
|
||||
defaultLogLevel: 3,
|
||||
logLevel: 3,
|
||||
enableSubtitleNudge: false,
|
||||
subtitleNudgeEnabledByDefault: true,
|
||||
subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost
|
||||
subtitleNudgeAmount: 0.001,
|
||||
customButtonIcons: {}
|
||||
@@ -59,8 +58,6 @@ var YT_NATIVE_MIN = 0.25;
|
||||
var YT_NATIVE_MAX = 2.0;
|
||||
var YT_NATIVE_STEP = 0.05;
|
||||
var vscObservedRoots = new WeakSet();
|
||||
var vscConnectedScannedRoots = new WeakSet();
|
||||
var vscInitializedDocuments = new Set();
|
||||
var requestIdle =
|
||||
typeof window.requestIdleCallback === "function"
|
||||
? window.requestIdleCallback.bind(window)
|
||||
@@ -387,7 +384,6 @@ function captureSiteRuleBase() {
|
||||
controllerMarginTop: tc.settings.controllerMarginTop,
|
||||
controllerMarginBottom: tc.settings.controllerMarginBottom,
|
||||
enableSubtitleNudge: tc.settings.enableSubtitleNudge,
|
||||
subtitleNudgeEnabledByDefault: tc.settings.subtitleNudgeEnabledByDefault,
|
||||
subtitleNudgeInterval: tc.settings.subtitleNudgeInterval,
|
||||
controllerButtons: Array.isArray(tc.settings.controllerButtons)
|
||||
? tc.settings.controllerButtons.slice()
|
||||
@@ -414,7 +410,6 @@ function resetSettingsFromSiteRuleBase() {
|
||||
tc.settings.controllerMarginTop = base.controllerMarginTop;
|
||||
tc.settings.controllerMarginBottom = base.controllerMarginBottom;
|
||||
tc.settings.enableSubtitleNudge = base.enableSubtitleNudge;
|
||||
tc.settings.subtitleNudgeEnabledByDefault = base.subtitleNudgeEnabledByDefault;
|
||||
tc.settings.subtitleNudgeInterval = base.subtitleNudgeInterval;
|
||||
tc.settings.controllerButtons = Array.isArray(base.controllerButtons)
|
||||
? base.controllerButtons.slice()
|
||||
@@ -664,15 +659,13 @@ function isSubtitleNudgeAvailableForVideo(video) {
|
||||
function isSubtitleNudgeEnabledForVideo(video) {
|
||||
if (!isSubtitleNudgeAvailableForVideo(video)) return false;
|
||||
|
||||
if (!video || !video.vsc) {
|
||||
return Boolean(tc.settings.subtitleNudgeEnabledByDefault);
|
||||
}
|
||||
if (!video || !video.vsc) return true;
|
||||
|
||||
if (typeof video.vsc.subtitleNudgeEnabledOverride === "boolean") {
|
||||
return video.vsc.subtitleNudgeEnabledOverride;
|
||||
}
|
||||
|
||||
return Boolean(tc.settings.subtitleNudgeEnabledByDefault);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setSubtitleNudgeEnabledForVideo(video, enabled) {
|
||||
@@ -976,8 +969,8 @@ function ensureController(node, parent) {
|
||||
}
|
||||
|
||||
// href selects site rules; re-run on every new/usable media so margins/opacity match current URL.
|
||||
applySiteRuleOverrides();
|
||||
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
|
||||
var siteDisabled = applySiteRuleOverrides();
|
||||
if (!tc.settings.enabled || siteDisabled) {
|
||||
return null;
|
||||
}
|
||||
refreshAllControllerGeometry();
|
||||
@@ -993,15 +986,6 @@ function ensureController(node, parent) {
|
||||
return node.vsc;
|
||||
}
|
||||
|
||||
function ensureControllerForMediaChild(node) {
|
||||
if (!node || node.nodeType !== Node.ELEMENT_NODE) return null;
|
||||
if (node.nodeName !== "SOURCE") return null;
|
||||
|
||||
var media = node.parentElement;
|
||||
if (!isMediaElement(media)) return null;
|
||||
return ensureController(media, media.parentElement || media.parentNode);
|
||||
}
|
||||
|
||||
function removeController(node) {
|
||||
if (node && node.vsc) node.vsc.remove();
|
||||
}
|
||||
@@ -1028,10 +1012,6 @@ function scanNodeForMedia(node, parent, added) {
|
||||
if (isMediaElement(node)) {
|
||||
if (added) ensureController(node, parent);
|
||||
else removeController(node);
|
||||
} else if (added) {
|
||||
// Players such as Vidstack often connect an empty <video> and append its
|
||||
// <source> later. Retry the owning video when that source becomes usable.
|
||||
ensureControllerForMediaChild(node);
|
||||
}
|
||||
|
||||
// Use querySelectorAll instead of recursive child walking — the browser's
|
||||
@@ -1054,22 +1034,6 @@ function scanNodeForMedia(node, parent, added) {
|
||||
if (node.shadowRoot) {
|
||||
observeRoot(node.shadowRoot);
|
||||
}
|
||||
|
||||
// Deep-scan descendant elements for shadow roots we haven't observed yet.
|
||||
// This catches custom elements (like archive.org's <play-av>) whose shadow
|
||||
// roots were created before our attachShadow patch was installed.
|
||||
if (added && typeof node.querySelectorAll === "function") {
|
||||
try {
|
||||
var allElements = node.querySelectorAll("*");
|
||||
for (var j = 0; j < allElements.length; j++) {
|
||||
if (allElements[j].shadowRoot && !vscObservedRoots.has(allElements[j].shadowRoot)) {
|
||||
observeRoot(allElements[j].shadowRoot);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// querySelectorAll may throw on detached or unusual nodes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getScanNodeForRoot(root) {
|
||||
@@ -1083,8 +1047,6 @@ function getScanNodeForRoot(root) {
|
||||
function rootMayContainMedia(root) {
|
||||
if (!root) return false;
|
||||
if (root.nodeType === Node.DOCUMENT_NODE) return true;
|
||||
// Always scan shadow roots so we can find nested shadow roots or media.
|
||||
if (root.host || (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot)) return true;
|
||||
if (typeof root.querySelector !== "function") return true;
|
||||
|
||||
try {
|
||||
@@ -1094,7 +1056,6 @@ function rootMayContainMedia(root) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function scanRootForMedia(root) {
|
||||
var scanRoot = getScanNodeForRoot(root);
|
||||
if (!scanRoot) return;
|
||||
@@ -1105,30 +1066,13 @@ function scanRootForMedia(root) {
|
||||
}
|
||||
|
||||
function observeRoot(root) {
|
||||
if (!root) return;
|
||||
|
||||
var isConnected = false;
|
||||
try {
|
||||
isConnected = root.nodeType === Node.DOCUMENT_NODE ||
|
||||
root.isConnected ||
|
||||
(root.host && (root.host.isConnected || (root.host.ownerDocument && root.host.ownerDocument.contains(root.host)))) ||
|
||||
(root.ownerDocument && root.ownerDocument.contains(root));
|
||||
} catch (e) {
|
||||
isConnected = true;
|
||||
}
|
||||
|
||||
if (!vscObservedRoots.has(root)) {
|
||||
vscObservedRoots.add(root);
|
||||
setupListener(root);
|
||||
attachMutationObserver(root);
|
||||
attachMediaDetectionListeners(root);
|
||||
}
|
||||
|
||||
if (isConnected && !vscConnectedScannedRoots.has(root)) {
|
||||
vscConnectedScannedRoots.add(root);
|
||||
if (rootMayContainMedia(root)) {
|
||||
scanRootForMedia(root);
|
||||
}
|
||||
if (!root || vscObservedRoots.has(root)) return;
|
||||
vscObservedRoots.add(root);
|
||||
setupListener(root);
|
||||
attachMutationObserver(root);
|
||||
attachMediaDetectionListeners(root);
|
||||
if (rootMayContainMedia(root)) {
|
||||
scanRootForMedia(root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1173,12 +1117,6 @@ function log(message, level) {
|
||||
}
|
||||
}
|
||||
|
||||
// Patch attachShadow immediately — before any async operations — so we
|
||||
// catch shadow roots created while chrome.storage.sync.get is pending.
|
||||
// Sites like archive.org create Lit/LitElement shadow DOMs during page load;
|
||||
// waiting for the storage callback would miss them entirely.
|
||||
patchAttachShadow();
|
||||
|
||||
chrome.storage.sync.get(tc.settings, function(storage) {
|
||||
var storedBindings = Array.isArray(storage.keyBindings)
|
||||
? storage.keyBindings
|
||||
@@ -1257,10 +1195,6 @@ chrome.storage.sync.get(tc.settings, function(storage) {
|
||||
typeof storage.enableSubtitleNudge !== "undefined"
|
||||
? Boolean(storage.enableSubtitleNudge)
|
||||
: tc.settings.enableSubtitleNudge;
|
||||
tc.settings.subtitleNudgeEnabledByDefault =
|
||||
typeof storage.subtitleNudgeEnabledByDefault !== "undefined"
|
||||
? Boolean(storage.subtitleNudgeEnabledByDefault)
|
||||
: tc.settings.subtitleNudgeEnabledByDefault;
|
||||
tc.settings.subtitleNudgeInterval = Math.min(
|
||||
1000,
|
||||
Math.max(10, Number(storage.subtitleNudgeInterval) || 50)
|
||||
@@ -1284,7 +1218,7 @@ chrome.storage.sync.get(tc.settings, function(storage) {
|
||||
chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings });
|
||||
}
|
||||
captureSiteRuleBase();
|
||||
// patchAttachShadow() is now called at top-level before this callback
|
||||
patchAttachShadow();
|
||||
// Add a listener for messages from the popup.
|
||||
// We use a global flag to ensure the listener is only attached once.
|
||||
if (!window.vscMessageListener) {
|
||||
@@ -1310,26 +1244,6 @@ chrome.storage.sync.get(tc.settings, function(storage) {
|
||||
sendResponse({ url: location.href });
|
||||
return false;
|
||||
}
|
||||
if (request.action === "set_force_last_saved_speed") {
|
||||
tc.settings.forceLastSavedSpeed = Boolean(request.enabled);
|
||||
if (isValidSpeed(Number(request.speed))) {
|
||||
tc.settings.lastSpeed = Number(request.speed);
|
||||
}
|
||||
var forceVideo = getPrimaryVideoElement();
|
||||
if (!forceVideo) return false;
|
||||
if (tc.settings.forceLastSavedSpeed) {
|
||||
tc.mediaElements.forEach(function(video) {
|
||||
if (!video || !video.vsc) return;
|
||||
setSpeed(video, tc.settings.lastSpeed, false, true);
|
||||
extendSpeedRestoreWindow(video);
|
||||
});
|
||||
}
|
||||
sendResponse({
|
||||
enabled: tc.settings.forceLastSavedSpeed,
|
||||
speed: forceVideo.playbackRate
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (request.action === "run_action") {
|
||||
var value = request.value;
|
||||
if (value === undefined || value === null) {
|
||||
@@ -1452,288 +1366,6 @@ function createControllerButton(doc, action, label, className) {
|
||||
return button;
|
||||
}
|
||||
|
||||
function createsControllerStackingContext(element) {
|
||||
if (!element || !element.ownerDocument) return false;
|
||||
|
||||
var win = element.ownerDocument.defaultView || window;
|
||||
var style = win.getComputedStyle(element);
|
||||
var position = style.position;
|
||||
var zIndex = style.zIndex;
|
||||
var contain = style.contain || "";
|
||||
var willChange = style.willChange || "";
|
||||
|
||||
return (
|
||||
style.isolation === "isolate" ||
|
||||
position === "fixed" ||
|
||||
position === "sticky" ||
|
||||
(position && position !== "static" && zIndex && zIndex !== "auto") ||
|
||||
(style.opacity !== "" && Number(style.opacity) < 1) ||
|
||||
(style.transform && style.transform !== "none") ||
|
||||
(style.filter && style.filter !== "none") ||
|
||||
(style.perspective && style.perspective !== "none") ||
|
||||
contain.indexOf("paint") !== -1 ||
|
||||
contain.indexOf("layout") !== -1 ||
|
||||
willChange.indexOf("transform") !== -1 ||
|
||||
willChange.indexOf("opacity") !== -1
|
||||
);
|
||||
}
|
||||
|
||||
function getControllerMount(video, boundary) {
|
||||
if (!video || !video.parentElement) return null;
|
||||
|
||||
var mountBoundary =
|
||||
boundary &&
|
||||
boundary !== video &&
|
||||
typeof boundary.contains === "function" &&
|
||||
boundary.contains(video)
|
||||
? boundary
|
||||
: null;
|
||||
|
||||
var videoRect = video.getBoundingClientRect();
|
||||
var mount = video.parentElement;
|
||||
var candidate = mount;
|
||||
var depth = 0;
|
||||
|
||||
// Player click-catchers are often siblings of the video's immediate parent.
|
||||
// Climb through tightly-sized wrappers so our host shares their stacking
|
||||
// context, but stop before broad page-layout containers.
|
||||
while (candidate && candidate.parentElement && depth < 5) {
|
||||
if (mountBoundary && candidate === mountBoundary) break;
|
||||
var next = candidate.parentElement;
|
||||
if (
|
||||
mountBoundary &&
|
||||
next !== mountBoundary &&
|
||||
!mountBoundary.contains(next)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
var nextRect = next.getBoundingClientRect();
|
||||
var widthLimit = Math.max(videoRect.width * 1.35, videoRect.width + 80);
|
||||
var heightLimit = Math.max(videoRect.height * 1.35, videoRect.height + 80);
|
||||
var containsVideo =
|
||||
nextRect.left <= videoRect.left + 1 &&
|
||||
nextRect.top <= videoRect.top + 1 &&
|
||||
nextRect.right >= videoRect.right - 1 &&
|
||||
nextRect.bottom >= videoRect.bottom - 1;
|
||||
|
||||
if (
|
||||
videoRect.width <= 0 ||
|
||||
videoRect.height <= 0 ||
|
||||
!containsVideo ||
|
||||
nextRect.width > widthLimit ||
|
||||
nextRect.height > heightLimit
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
mount = next;
|
||||
candidate = next;
|
||||
depth += 1;
|
||||
|
||||
// In fullscreen, the wrapper must remain inside the exact subtree the
|
||||
// browser promotes to its top layer.
|
||||
if (mountBoundary && next === mountBoundary) break;
|
||||
|
||||
// Never climb out of a player-owned stacking context. Doing so lets the
|
||||
// controller's high local z-index escape above sticky page headers.
|
||||
if (createsControllerStackingContext(next)) break;
|
||||
}
|
||||
|
||||
return mount;
|
||||
}
|
||||
|
||||
function getFullscreenElement(doc) {
|
||||
if (!doc) return null;
|
||||
return (
|
||||
doc.fullscreenElement ||
|
||||
doc.webkitFullscreenElement ||
|
||||
doc.mozFullScreenElement ||
|
||||
doc.msFullscreenElement ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function positionControllerHost(wrapper, video, mount) {
|
||||
if (!wrapper || !video || !mount || !wrapper.isConnected) return;
|
||||
|
||||
var videoRect = video.getBoundingClientRect();
|
||||
var mountRect = mount.getBoundingClientRect();
|
||||
if (videoRect.width <= 0 || videoRect.height <= 0) return;
|
||||
|
||||
// Convert viewport pixels back into the mount's CSS pixel space. This keeps
|
||||
// the overlay aligned even when a player or ancestor is scaled.
|
||||
var mountWidth = mount.offsetWidth || mountRect.width || 1;
|
||||
var mountHeight = mount.offsetHeight || mountRect.height || 1;
|
||||
var scaleX = mountRect.width > 0 ? mountRect.width / mountWidth : 1;
|
||||
var scaleY = mountRect.height > 0 ? mountRect.height / mountHeight : 1;
|
||||
var left =
|
||||
(videoRect.left - mountRect.left) / scaleX -
|
||||
(mount.clientLeft || 0) +
|
||||
(mount.scrollLeft || 0);
|
||||
var top =
|
||||
(videoRect.top - mountRect.top) / scaleY -
|
||||
(mount.clientTop || 0) +
|
||||
(mount.scrollTop || 0);
|
||||
|
||||
wrapper.style.setProperty("left", left + "px", "important");
|
||||
wrapper.style.setProperty("top", top + "px", "important");
|
||||
wrapper.style.setProperty(
|
||||
"width",
|
||||
videoRect.width / scaleX + "px",
|
||||
"important"
|
||||
);
|
||||
wrapper.style.setProperty(
|
||||
"height",
|
||||
videoRect.height / scaleY + "px",
|
||||
"important"
|
||||
);
|
||||
}
|
||||
|
||||
function setupControllerHostTracking(videoController, wrapper, mount) {
|
||||
if (!videoController || !wrapper || !mount) return;
|
||||
|
||||
var doc = videoController.video.ownerDocument;
|
||||
var win = doc.defaultView || window;
|
||||
var frameId = null;
|
||||
var update = function() {
|
||||
frameId = null;
|
||||
positionControllerHost(wrapper, videoController.video, mount);
|
||||
};
|
||||
var schedule = function() {
|
||||
if (frameId !== null) return;
|
||||
frameId = win.requestAnimationFrame(update);
|
||||
};
|
||||
|
||||
if (win.getComputedStyle(mount).position === "static") {
|
||||
mount.dataset.vscPositionOwner = "true";
|
||||
mount.dataset.vscOriginalPosition = mount.style.getPropertyValue("position");
|
||||
mount.dataset.vscOriginalPositionPriority =
|
||||
mount.style.getPropertyPriority("position");
|
||||
mount.style.setProperty("position", "relative");
|
||||
}
|
||||
|
||||
if (!createsControllerStackingContext(mount)) {
|
||||
mount.dataset.vscIsolationOwner = "true";
|
||||
mount.dataset.vscOriginalIsolation =
|
||||
mount.style.getPropertyValue("isolation");
|
||||
mount.dataset.vscOriginalIsolationPriority =
|
||||
mount.style.getPropertyPriority("isolation");
|
||||
mount.style.setProperty("isolation", "isolate");
|
||||
}
|
||||
|
||||
var resizeObserver = null;
|
||||
if (typeof win.ResizeObserver === "function") {
|
||||
resizeObserver = new win.ResizeObserver(schedule);
|
||||
resizeObserver.observe(videoController.video);
|
||||
resizeObserver.observe(mount);
|
||||
}
|
||||
|
||||
win.addEventListener("resize", schedule, { passive: true });
|
||||
doc.addEventListener("fullscreenchange", schedule, { passive: true });
|
||||
mount.addEventListener("scroll", schedule, { passive: true });
|
||||
update();
|
||||
|
||||
videoController.controllerHostMount = mount;
|
||||
videoController.controllerHostCleanup = function(forceRestore) {
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
if (frameId !== null) win.cancelAnimationFrame(frameId);
|
||||
win.removeEventListener("resize", schedule);
|
||||
doc.removeEventListener("fullscreenchange", schedule);
|
||||
mount.removeEventListener("scroll", schedule);
|
||||
var hasOtherController = false;
|
||||
try {
|
||||
hasOtherController = Array.from(
|
||||
mount.querySelectorAll(".vsc-controller")
|
||||
).some(function(controllerHost) {
|
||||
return controllerHost !== wrapper;
|
||||
});
|
||||
} catch (e) {}
|
||||
if (
|
||||
mount.dataset.vscPositionOwner === "true" &&
|
||||
!hasOtherController &&
|
||||
(forceRestore === true || !wrapper.isConnected)
|
||||
) {
|
||||
if (mount.dataset.vscOriginalPosition) {
|
||||
mount.style.setProperty(
|
||||
"position",
|
||||
mount.dataset.vscOriginalPosition,
|
||||
mount.dataset.vscOriginalPositionPriority || ""
|
||||
);
|
||||
} else {
|
||||
mount.style.removeProperty("position");
|
||||
}
|
||||
delete mount.dataset.vscPositionOwner;
|
||||
delete mount.dataset.vscOriginalPosition;
|
||||
delete mount.dataset.vscOriginalPositionPriority;
|
||||
}
|
||||
if (
|
||||
mount.dataset.vscIsolationOwner === "true" &&
|
||||
!hasOtherController &&
|
||||
(forceRestore === true || !wrapper.isConnected)
|
||||
) {
|
||||
if (mount.dataset.vscOriginalIsolation) {
|
||||
mount.style.setProperty(
|
||||
"isolation",
|
||||
mount.dataset.vscOriginalIsolation,
|
||||
mount.dataset.vscOriginalIsolationPriority || ""
|
||||
);
|
||||
} else {
|
||||
mount.style.removeProperty("isolation");
|
||||
}
|
||||
delete mount.dataset.vscIsolationOwner;
|
||||
delete mount.dataset.vscOriginalIsolation;
|
||||
delete mount.dataset.vscOriginalIsolationPriority;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function remountControllerHost(videoController, mount) {
|
||||
if (
|
||||
!videoController ||
|
||||
!videoController.div ||
|
||||
!mount ||
|
||||
!mount.isConnected ||
|
||||
mount === videoController.controllerHostMount
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (videoController.controllerHostCleanup) {
|
||||
videoController.controllerHostCleanup(true);
|
||||
videoController.controllerHostCleanup = null;
|
||||
}
|
||||
|
||||
mount.insertBefore(videoController.div, mount.firstChild);
|
||||
setupControllerHostTracking(videoController, videoController.div, mount);
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncControllerFullscreenMount(videoController) {
|
||||
if (!videoController || !videoController.video || !videoController.div) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var video = videoController.video;
|
||||
var doc = video.ownerDocument;
|
||||
var fullscreenElement = getFullscreenElement(doc);
|
||||
var targetMount = videoController.normalControllerMount;
|
||||
|
||||
if (
|
||||
fullscreenElement &&
|
||||
fullscreenElement !== video &&
|
||||
typeof fullscreenElement.contains === "function" &&
|
||||
fullscreenElement.contains(video)
|
||||
) {
|
||||
targetMount = getControllerMount(video, fullscreenElement);
|
||||
} else if (!fullscreenElement && (!targetMount || !targetMount.isConnected)) {
|
||||
targetMount = getControllerMount(video);
|
||||
videoController.normalControllerMount = targetMount;
|
||||
}
|
||||
|
||||
if (!targetMount) return false;
|
||||
return remountControllerHost(videoController, targetMount);
|
||||
}
|
||||
|
||||
function defineVideoController() {
|
||||
tc.videoController = function(target, parent) {
|
||||
if (target.vsc) return target.vsc;
|
||||
@@ -1783,7 +1415,6 @@ function defineVideoController() {
|
||||
event.type === "loadeddata" ||
|
||||
event.type === "canplay"
|
||||
) {
|
||||
if (this.div) this.div.classList.remove("vsc-nosource");
|
||||
applySourceTransitionPolicy(event.target, false);
|
||||
}
|
||||
|
||||
@@ -1879,10 +1510,7 @@ function defineVideoController() {
|
||||
if (this.div) {
|
||||
this.stopSubtitleNudge();
|
||||
if (!mutation.target.src && !mutation.target.currentSrc) {
|
||||
this.div.classList.toggle(
|
||||
"vsc-nosource",
|
||||
!hasUsableMediaSource(mutation.target)
|
||||
);
|
||||
this.div.classList.add("vsc-nosource");
|
||||
} else {
|
||||
this.div.classList.remove("vsc-nosource");
|
||||
applySourceTransitionPolicy(this.video, true);
|
||||
@@ -1914,10 +1542,6 @@ function defineVideoController() {
|
||||
this.genericAutoHideCleanup = null;
|
||||
}
|
||||
if (this.div) this.div.remove();
|
||||
if (this.controllerHostCleanup) {
|
||||
this.controllerHostCleanup();
|
||||
this.controllerHostCleanup = null;
|
||||
}
|
||||
if (this.restoreSpeedTimer) clearTimeout(this.restoreSpeedTimer);
|
||||
if (this.video) {
|
||||
this.video.removeEventListener("loadedmetadata", this.handleLoadedMetadata);
|
||||
@@ -2196,17 +1820,15 @@ function defineVideoController() {
|
||||
const speed = this.video.playbackRate.toFixed(2);
|
||||
var wrapper = doc.createElement("div");
|
||||
wrapper.classList.add("vsc-controller");
|
||||
if (!hasUsableMediaSource(this.video))
|
||||
if (!this.video.src && !this.video.currentSrc)
|
||||
wrapper.classList.add("vsc-nosource");
|
||||
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
|
||||
// z-index is handled by the base .vsc-controller CSS rule (2147483646).
|
||||
// The controller lives inside the video container, so high z-index only
|
||||
// makes it topmost within the local stacking context — it won't overlay
|
||||
// page-level modals or dialogs.
|
||||
// Use lower z-index for non-YouTube sites to avoid overlapping modals
|
||||
if (!isOnYouTube()) wrapper.classList.add("vsc-non-youtube");
|
||||
var shadow = wrapper.attachShadow({ mode: "open" });
|
||||
var shadowStylesheet = doc.createElement("link");
|
||||
shadowStylesheet.rel = "stylesheet";
|
||||
shadowStylesheet.href = chrome.runtime.getURL("content/shadow.css");
|
||||
shadowStylesheet.href = chrome.runtime.getURL("shadow.css");
|
||||
shadow.appendChild(shadowStylesheet);
|
||||
|
||||
var controller = doc.createElement("div");
|
||||
@@ -2327,58 +1949,65 @@ function defineVideoController() {
|
||||
}
|
||||
}
|
||||
|
||||
var fragment = doc.createDocumentFragment();
|
||||
fragment.appendChild(wrapper);
|
||||
const parentEl = this.parent || this.video.parentElement;
|
||||
var mountEl = getControllerMount(this.video) || parentEl;
|
||||
|
||||
log(`Inserting controller: parentEl=${!!parentEl}, parentNode=${!!parentEl?.parentNode}, hostname=${location.hostname}`, 4);
|
||||
|
||||
if (!parentEl || !parentEl.parentNode) {
|
||||
log("No suitable parent found, appending to body", 4);
|
||||
doc.body.appendChild(wrapper);
|
||||
this.normalControllerMount = doc.body;
|
||||
setupControllerHostTracking(this, wrapper, doc.body);
|
||||
doc.body.appendChild(fragment);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (true) {
|
||||
case location.hostname === "www.amazon.com":
|
||||
case location.hostname === "www.reddit.com":
|
||||
case location.hostname == "www.amazon.com":
|
||||
case location.hostname == "www.reddit.com":
|
||||
case /hbogo\./.test(location.hostname):
|
||||
mountEl = parentEl.parentElement || mountEl;
|
||||
log("Using parentElement.parentElement insertion", 5);
|
||||
parentEl.parentElement.insertBefore(fragment, parentEl);
|
||||
break;
|
||||
case location.hostname === "www.facebook.com":
|
||||
var facebookMount = parentEl;
|
||||
for (var facebookDepth = 0; facebookDepth < 7; facebookDepth += 1) {
|
||||
if (!facebookMount.parentElement) break;
|
||||
facebookMount = facebookMount.parentElement;
|
||||
case location.hostname == "www.facebook.com":
|
||||
log("Using Facebook-specific insertion", 5);
|
||||
let p =
|
||||
parentEl.parentElement.parentElement.parentElement.parentElement
|
||||
.parentElement.parentElement.parentElement;
|
||||
if (p && p.firstChild) p.insertBefore(fragment, p.firstChild);
|
||||
else parentEl.insertBefore(fragment, parentEl.firstChild);
|
||||
break;
|
||||
case location.hostname == "tv.apple.com":
|
||||
log("Using Apple TV-specific insertion", 5);
|
||||
const r = parentEl.getRootNode();
|
||||
const s = r && r.querySelector ? r.querySelector(".scrim") : null;
|
||||
if (s) s.prepend(fragment);
|
||||
else parentEl.insertBefore(fragment, parentEl.firstChild);
|
||||
break;
|
||||
case location.hostname == "www.youtube.com":
|
||||
case location.hostname == "m.youtube.com":
|
||||
case location.hostname == "music.youtube.com":
|
||||
// YouTube's player DOM has .html5-video-container (video's parent) as a
|
||||
// low layer with overlay siblings (.ytp-player-content, etc.) on top that
|
||||
// intercept mouse events. Insert into .html5-video-player (the player
|
||||
// root) so the controller sits above all overlay layers.
|
||||
log("Using YouTube-specific insertion", 5);
|
||||
var ytPlayer = parentEl.closest(".html5-video-player");
|
||||
if (ytPlayer) {
|
||||
ytPlayer.insertBefore(fragment, ytPlayer.firstChild);
|
||||
} else {
|
||||
parentEl.insertBefore(fragment, parentEl.firstChild);
|
||||
}
|
||||
mountEl = facebookMount || mountEl;
|
||||
break;
|
||||
case location.hostname === "tv.apple.com":
|
||||
var appleRoot = parentEl.getRootNode();
|
||||
var appleScrim =
|
||||
appleRoot && appleRoot.querySelector
|
||||
? appleRoot.querySelector(".scrim")
|
||||
: null;
|
||||
mountEl = appleScrim || mountEl;
|
||||
break;
|
||||
case location.hostname === "www.youtube.com":
|
||||
case location.hostname === "m.youtube.com":
|
||||
case location.hostname === "music.youtube.com":
|
||||
mountEl = parentEl.closest(".html5-video-player") || mountEl;
|
||||
break;
|
||||
default:
|
||||
log("Using default insertion method", 5);
|
||||
parentEl.insertBefore(fragment, parentEl.firstChild);
|
||||
}
|
||||
mountEl.insertBefore(wrapper, mountEl.firstChild);
|
||||
this.normalControllerMount = mountEl;
|
||||
setupControllerHostTracking(this, wrapper, mountEl);
|
||||
log("Controller successfully inserted into DOM", 4);
|
||||
} catch (error) {
|
||||
log(`Error inserting controller: ${error.message}`, 2);
|
||||
// Fallback to body insertion
|
||||
doc.body.appendChild(wrapper);
|
||||
this.normalControllerMount = doc.body;
|
||||
setupControllerHostTracking(this, wrapper, doc.body);
|
||||
doc.body.appendChild(fragment);
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
@@ -2387,7 +2016,6 @@ function defineVideoController() {
|
||||
|
||||
function applySiteRuleOverrides() {
|
||||
resetSettingsFromSiteRuleBase();
|
||||
tc.activeSiteRule = null;
|
||||
|
||||
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
|
||||
return false;
|
||||
@@ -2396,9 +2024,7 @@ function applySiteRuleOverrides() {
|
||||
var currentUrl = location.href;
|
||||
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
|
||||
|
||||
if (!matchedRule) {
|
||||
return false;
|
||||
}
|
||||
if (!matchedRule) return false;
|
||||
|
||||
tc.activeSiteRule = matchedRule;
|
||||
log(`Matched site rule: ${matchedRule.pattern}`, 4);
|
||||
@@ -2422,7 +2048,6 @@ function applySiteRuleOverrides() {
|
||||
"controllerMarginTop",
|
||||
"controllerMarginBottom",
|
||||
"enableSubtitleNudge",
|
||||
"subtitleNudgeEnabledByDefault",
|
||||
"subtitleNudgeInterval"
|
||||
];
|
||||
|
||||
@@ -2479,10 +2104,8 @@ function refreshAllControllerGeometry() {
|
||||
|
||||
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
|
||||
function reapplySiteRulesAndControllerGeometry() {
|
||||
applySiteRuleOverrides();
|
||||
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
|
||||
return;
|
||||
}
|
||||
var siteDisabled = applySiteRuleOverrides();
|
||||
if (!tc.settings.enabled || siteDisabled) return;
|
||||
refreshAllControllerGeometry();
|
||||
}
|
||||
|
||||
@@ -2570,6 +2193,8 @@ function setupListener(root) {
|
||||
root.vscRateListenerAttached = true;
|
||||
}
|
||||
|
||||
var vscInitializedDocuments = new Set();
|
||||
|
||||
function clearPendingInitialization(doc) {
|
||||
if (!doc || !doc.vscPendingInitializeHandler) return;
|
||||
|
||||
@@ -2724,15 +2349,6 @@ function attachMutationObserver(root) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
target.nodeName === "SOURCE" &&
|
||||
mutation.attributeName === "src"
|
||||
) {
|
||||
ensureControllerForMediaChild(target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
mutation.attributeName === "aria-hidden" &&
|
||||
target.attributes["aria-hidden"] &&
|
||||
@@ -2837,10 +2453,8 @@ function attachNavigationListeners() {
|
||||
function initializeNow(doc, forceReinit = false) {
|
||||
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return;
|
||||
|
||||
applySiteRuleOverrides();
|
||||
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
|
||||
return;
|
||||
}
|
||||
var siteDisabled = applySiteRuleOverrides();
|
||||
if (!tc.settings.enabled || siteDisabled) return;
|
||||
|
||||
if (!doc.body.classList.contains("vsc-initialized")) {
|
||||
doc.body.classList.add("vsc-initialized");
|
||||
@@ -2850,29 +2464,8 @@ function initializeNow(doc, forceReinit = false) {
|
||||
attachNavigationListeners();
|
||||
observeRoot(doc);
|
||||
|
||||
// Delayed rescan to catch custom elements whose shadow roots were created
|
||||
// before our attachShadow patch was installed (e.g. archive.org's <play-av>
|
||||
// Lit component, or any site that lazily creates shadow DOM video players).
|
||||
if (!doc.vscDelayedShadowScanDone) {
|
||||
doc.vscDelayedShadowScanDone = true;
|
||||
setTimeout(function() {
|
||||
if (!doc.body) return;
|
||||
try {
|
||||
var els = doc.body.querySelectorAll("*");
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
if (els[i].shadowRoot && !vscObservedRoots.has(els[i].shadowRoot)) {
|
||||
observeRoot(els[i].shadowRoot);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
if (forceReinit) {
|
||||
log("Force re-initialization requested", 4);
|
||||
// A root is normally scanned only once. A user-requested rescan must also
|
||||
// revisit media that was present but source-less during the initial scan.
|
||||
scanRootForMedia(doc);
|
||||
refreshAllControllerGeometry();
|
||||
}
|
||||
|
||||
@@ -3313,22 +2906,11 @@ function showController(controller, duration = 2000, forced = false) {
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// Keep each controller inside the subtree promoted to the fullscreen top layer,
|
||||
// then restore its normal player-local mount when fullscreen exits.
|
||||
function handleFullscreenControllerTransition() {
|
||||
// Add global listener to handle fullscreen transitions and adjust controller positions
|
||||
document.addEventListener("fullscreenchange", () => {
|
||||
tc.mediaElements.forEach((video) => {
|
||||
if (video.vsc) {
|
||||
syncControllerFullscreenMount(video.vsc);
|
||||
applyControllerLocation(video.vsc, video.vsc.controllerLocation);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[
|
||||
"fullscreenchange",
|
||||
"webkitfullscreenchange",
|
||||
"mozfullscreenchange",
|
||||
"MSFullscreenChange"
|
||||
].forEach(function(eventName) {
|
||||
document.addEventListener(eventName, handleFullscreenControllerTransition);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Speeder",
|
||||
"short_name": "Speeder",
|
||||
"version": "5.3.5.0",
|
||||
"version": "5.2.4",
|
||||
"manifest_version": 2,
|
||||
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
|
||||
"homepage_url": "https://github.com/SoPat712/speeder",
|
||||
@@ -16,13 +16,13 @@
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "assets/icons/icon16.png",
|
||||
"48": "assets/icons/icon48.png",
|
||||
"128": "assets/icons/icon128.png"
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"background": {
|
||||
"scripts": [
|
||||
"background/background.js"
|
||||
"background.js"
|
||||
]
|
||||
},
|
||||
"permissions": [
|
||||
@@ -30,16 +30,16 @@
|
||||
"https://cdn.jsdelivr.net/*"
|
||||
],
|
||||
"options_ui": {
|
||||
"page": "options/options.html",
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"browser_action": {
|
||||
"default_icon": {
|
||||
"19": "assets/icons/icon19.png",
|
||||
"38": "assets/icons/icon38.png",
|
||||
"48": "assets/icons/icon48.png"
|
||||
"19": "icons/icon19.png",
|
||||
"38": "icons/icon38.png",
|
||||
"48": "icons/icon48.png"
|
||||
},
|
||||
"default_popup": "popup/popup.html"
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
@@ -56,19 +56,19 @@
|
||||
"https://meet.google.com/*"
|
||||
],
|
||||
"css": [
|
||||
"content/inject.css"
|
||||
"inject.css"
|
||||
],
|
||||
"js": [
|
||||
"shared/controller-utils.js",
|
||||
"shared/key-bindings.js",
|
||||
"shared/site-rules.js",
|
||||
"shared/ui-icons.js",
|
||||
"content/inject.js"
|
||||
"ui-icons.js",
|
||||
"inject.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"content/inject.css",
|
||||
"content/shadow.css"
|
||||
"inject.css",
|
||||
"shadow.css"
|
||||
]
|
||||
}
|
||||
@@ -343,40 +343,11 @@ label em {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shortcut-label em {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.customKey,
|
||||
.customValue {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Chevron: native menu indicator is often missing with themed controls */
|
||||
#addShortcutSelector,
|
||||
.site-add-shortcut-selector {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-color: var(--panel);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%234b5563' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
background-size: 16px 16px;
|
||||
padding-right: 38px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#addShortcutSelector:disabled,
|
||||
.site-add-shortcut-selector:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
#addShortcutSelector {
|
||||
width: min(220px, 100%);
|
||||
margin-top: 12px;
|
||||
@@ -518,7 +489,7 @@ label em {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
@@ -531,11 +502,6 @@ label em {
|
||||
|
||||
.site-override-lead span {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.site-override-lead span em {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.site-rule-override-section .site-override-fields,
|
||||
@@ -969,10 +935,6 @@ button.lucide-result-tile.lucide-picked {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.site-rule-split-label span em {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.site-rule-split-label input[type="checkbox"] {
|
||||
justify-self: end;
|
||||
margin-top: 0;
|
||||
@@ -1007,22 +969,16 @@ button.lucide-result-tile.lucide-picked {
|
||||
}
|
||||
|
||||
.site-shortcuts-container .shortcut-row {
|
||||
grid-template-columns: minmax(0, 1fr) 110px 110px minmax(0, 1fr) 38px;
|
||||
grid-template-columns: minmax(0, 1fr) 110px 110px minmax(0, 1fr);
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.site-shortcuts-rows .shortcut-row:first-child {
|
||||
.site-shortcuts-container .shortcut-row:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.site-add-shortcut-selector {
|
||||
width: min(220px, 100%);
|
||||
align-self: flex-start;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.force-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1164,8 +1120,7 @@ button.lucide-result-tile.lucide-picked {
|
||||
}
|
||||
|
||||
.action-row button,
|
||||
#addShortcutSelector,
|
||||
.site-add-shortcut-selector {
|
||||
#addShortcutSelector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1249,12 +1204,6 @@ button.lucide-result-tile.lucide-picked {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
#addShortcutSelector,
|
||||
.site-add-shortcut-selector {
|
||||
background-color: var(--panel);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
@@ -5,14 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Speeder Settings</title>
|
||||
<link rel="stylesheet" href="options.css" />
|
||||
<script src="../shared/controller-utils.js"></script>
|
||||
<script src="../shared/key-bindings.js"></script>
|
||||
<script src="../shared/popup-controls.js"></script>
|
||||
<script src="../shared/ui-icons.js"></script>
|
||||
<script src="shared/controller-utils.js"></script>
|
||||
<script src="shared/key-bindings.js"></script>
|
||||
<script src="shared/popup-controls.js"></script>
|
||||
<script src="ui-icons.js"></script>
|
||||
<script src="lucide-client.js"></script>
|
||||
<script src="options.js"></script>
|
||||
<script src="../shared/import-export.js"></script>
|
||||
<script src="import-export.js"></script>
|
||||
<script src="shared/import-export.js"></script>
|
||||
<script src="importExport.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
@@ -38,7 +38,7 @@
|
||||
>
|
||||
<img
|
||||
class="support-cta-kofi-img"
|
||||
src="../assets/images/kofi_symbol.svg"
|
||||
src="images/kofi_symbol.svg"
|
||||
width="241"
|
||||
height="194"
|
||||
alt=""
|
||||
@@ -96,11 +96,7 @@
|
||||
<section id="customs" class="settings-card">
|
||||
<div class="section-heading">
|
||||
<h3>Shortcuts</h3>
|
||||
<p class="section-intro">
|
||||
Backspace clears a key. Escape disables optional shortcuts. If a site
|
||||
steals a shortcut, use a site rule with Override shortcuts (and
|
||||
per-key blocking) for that URL.
|
||||
</p>
|
||||
<p class="section-intro">Backspace clears a shortcut. Escape disables it.</p>
|
||||
</div>
|
||||
<div class="shortcuts-grid">
|
||||
<div class="shortcut-row" id="display" data-action="display">
|
||||
@@ -136,10 +132,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="shortcut-row" id="slower" data-action="slower">
|
||||
<div class="shortcut-label">
|
||||
Decrease speed
|
||||
<em>Required: Speeder needs a key for this action.</em>
|
||||
</div>
|
||||
<div class="shortcut-label">Decrease speed</div>
|
||||
<input
|
||||
class="customKey"
|
||||
type="text"
|
||||
@@ -153,10 +146,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="shortcut-row" id="faster" data-action="faster">
|
||||
<div class="shortcut-label">
|
||||
Increase speed
|
||||
<em>Required: Speeder needs a key for this action.</em>
|
||||
</div>
|
||||
<div class="shortcut-label">Increase speed</div>
|
||||
<input
|
||||
class="customKey"
|
||||
type="text"
|
||||
@@ -263,22 +253,11 @@
|
||||
<h4 class="defaults-sub-heading">General</h4>
|
||||
|
||||
<div class="row row-checkbox">
|
||||
<label for="enabled"
|
||||
>Enable<br />
|
||||
<em
|
||||
>On: site rules can block sites (blacklist). Off: only matched rules keep Speeder on (whitelist).</em
|
||||
>
|
||||
</label>
|
||||
<label for="enabled">Enable</label>
|
||||
<input id="enabled" type="checkbox" />
|
||||
</div>
|
||||
<div class="row row-checkbox">
|
||||
<label for="audioBoolean"
|
||||
>Work on audio<br />
|
||||
<em
|
||||
>Also controls plain HTML5 audio (not just video). Turn off if
|
||||
you only want Speeder on video players.</em
|
||||
>
|
||||
</label>
|
||||
<label for="audioBoolean">Work on audio</label>
|
||||
<input id="audioBoolean" type="checkbox" />
|
||||
</div>
|
||||
|
||||
@@ -286,14 +265,7 @@
|
||||
<h4 class="defaults-sub-heading">Playback</h4>
|
||||
|
||||
<div class="row row-checkbox">
|
||||
<label for="rememberSpeed"
|
||||
>Remember playback speed<br />
|
||||
<em
|
||||
>Stores speed per source so revisiting the same media can restore
|
||||
it. Separate from “Force last saved speed,” which
|
||||
fights players that reset rate.</em
|
||||
>
|
||||
</label>
|
||||
<label for="rememberSpeed">Remember playback speed</label>
|
||||
<input id="rememberSpeed" type="checkbox" />
|
||||
</div>
|
||||
<div class="row row-checkbox">
|
||||
@@ -311,23 +283,11 @@
|
||||
<h4 class="defaults-sub-heading">Controller</h4>
|
||||
|
||||
<div class="row row-checkbox">
|
||||
<label for="startHidden"
|
||||
>Hide controller by default<br />
|
||||
<em
|
||||
>Starts with the overlay hidden; use shortcuts (show/hide,
|
||||
move) or site behavior to reveal it.</em
|
||||
>
|
||||
</label>
|
||||
<label for="startHidden">Hide controller by default</label>
|
||||
<input id="startHidden" type="checkbox" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="controllerLocation"
|
||||
>Default controller location<br />
|
||||
<em
|
||||
>Corner or edge anchor for the hover bar. Site rules can override
|
||||
this for specific URLs.</em
|
||||
>
|
||||
</label>
|
||||
<label for="controllerLocation">Default controller location</label>
|
||||
<select id="controllerLocation">
|
||||
<option value="top-left">Top left</option>
|
||||
<option value="top-center">Top center</option>
|
||||
@@ -340,13 +300,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="controllerOpacity"
|
||||
>Controller opacity<br />
|
||||
<em
|
||||
>0–1 (decimals). Lower is more transparent. Applies to the
|
||||
in-page controller only.</em
|
||||
>
|
||||
</label>
|
||||
<label for="controllerOpacity">Controller opacity</label>
|
||||
<input id="controllerOpacity" type="text" value="" />
|
||||
</div>
|
||||
<div class="row row-controller-margin">
|
||||
@@ -394,21 +348,11 @@
|
||||
<div class="row row-checkbox">
|
||||
<label for="enableSubtitleNudge"
|
||||
>Enable subtitle nudge<br /><em
|
||||
>Makes tiny playback changes to help keep subtitles aligned and
|
||||
allows nudge toggle controls.</em
|
||||
>Makes tiny playback changes to help keep subtitles aligned.</em
|
||||
>
|
||||
</label>
|
||||
<input id="enableSubtitleNudge" type="checkbox" />
|
||||
</div>
|
||||
<div class="row row-checkbox">
|
||||
<label for="subtitleNudgeEnabledByDefault"
|
||||
>Start subtitle nudge enabled<br /><em
|
||||
>When off, each video starts with nudging disabled until you
|
||||
toggle it.</em
|
||||
>
|
||||
</label>
|
||||
<input id="subtitleNudgeEnabledByDefault" type="checkbox" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="subtitleNudgeInterval"
|
||||
>Nudge interval (milliseconds)<br /><em
|
||||
@@ -472,23 +416,11 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="row row-checkbox">
|
||||
<label for="showPopupControlBar"
|
||||
>Show popup control bar<br />
|
||||
<em
|
||||
>Shows buttons in the extension popup (toolbar icon). Can be
|
||||
overridden per site in site rules.</em
|
||||
>
|
||||
</label>
|
||||
<label for="showPopupControlBar">Show popup control bar</label>
|
||||
<input id="showPopupControlBar" type="checkbox" />
|
||||
</div>
|
||||
<div class="row row-checkbox">
|
||||
<label for="popupMatchHoverControls"
|
||||
>Match hover controls<br />
|
||||
<em
|
||||
>When on, the popup copies the hover bar’s buttons and
|
||||
order. When off, customize the popup layout below.</em
|
||||
>
|
||||
</label>
|
||||
<label for="popupMatchHoverControls">Match hover controls</label>
|
||||
<input id="popupMatchHoverControls" type="checkbox" />
|
||||
</div>
|
||||
<div id="popupCbEditorWrap" class="cb-editor cb-editor-disabled">
|
||||
@@ -636,36 +568,19 @@
|
||||
<div class="site-rule-body">
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label class="site-rule-split-label">
|
||||
<span
|
||||
>Enable Speeder on this site<br /><em
|
||||
>For this URL pattern only: off blocks when global Speeder
|
||||
is on (blacklist); on allows when global is off
|
||||
(whitelist)—same pairing as Defaults →
|
||||
Enable.</em
|
||||
></span
|
||||
>
|
||||
<span>Enable Speeder on this site</span>
|
||||
<input type="checkbox" class="site-enabled" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="site-rule-content">
|
||||
<div class="site-rule-override-section">
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override placement for this site<br /><em
|
||||
>When on, location and margin below replace general
|
||||
defaults for matching URLs.</em
|
||||
></span
|
||||
>
|
||||
<span>Override placement for this site</span>
|
||||
<input type="checkbox" class="override-placement" />
|
||||
</label>
|
||||
<div class="site-placement-container">
|
||||
<div class="site-rule-option site-rule-option-field">
|
||||
<label
|
||||
>Default controller location:<br /><em
|
||||
>Corner or edge anchor for the hover bar. Replaces the
|
||||
general default for matching URLs only.</em
|
||||
></label
|
||||
>
|
||||
<label>Default controller location:</label>
|
||||
<select class="site-controllerLocation">
|
||||
<option value="top-left">Top left</option>
|
||||
<option value="top-center">Top center</option>
|
||||
@@ -680,8 +595,7 @@
|
||||
<div class="site-rule-option site-rule-margin-option">
|
||||
<label
|
||||
>Controller margin (px):<br /><em
|
||||
>Shifts the whole control from its preset position (CSS
|
||||
margins). Top and bottom. 0–200.</em
|
||||
>Shifts the whole control. 0–200.</em
|
||||
></label
|
||||
>
|
||||
<div class="controller-margin-inputs">
|
||||
@@ -699,172 +613,85 @@
|
||||
</div>
|
||||
<div class="site-rule-override-section">
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override hide-by-default for this site<br /><em
|
||||
>When on, the hide-by-default toggle below replaces the
|
||||
general default for matching URLs.</em
|
||||
></span
|
||||
>
|
||||
<span>Override hide-by-default for this site</span>
|
||||
<input type="checkbox" class="override-visibility" />
|
||||
</label>
|
||||
<div class="site-visibility-container">
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label
|
||||
>Hide controller by default:<br /><em
|
||||
>Starts with the overlay hidden; use shortcuts
|
||||
(show/hide, move) or site behavior to reveal it.</em
|
||||
></label
|
||||
>
|
||||
<label>Hide controller by default:</label>
|
||||
<input type="checkbox" class="site-startHidden" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-rule-override-section">
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override auto-hide for this site<br /><em
|
||||
>When on, hide-with-controls and timer below replace
|
||||
general defaults for matching URLs.</em
|
||||
></span
|
||||
>
|
||||
<span>Override auto-hide for this site</span>
|
||||
<input type="checkbox" class="override-autohide" />
|
||||
</label>
|
||||
<div class="site-autohide-container">
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label class="site-rule-split-label">
|
||||
<span
|
||||
>Hide with controls (idle-based)<br /><em
|
||||
>Fade the controller in and out with the video
|
||||
interface: perfect sync on YouTube, idle-based
|
||||
elsewhere.</em
|
||||
></span
|
||||
>
|
||||
<span>Hide with controls (idle-based)</span>
|
||||
<input type="checkbox" class="site-hideWithControls" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-field">
|
||||
<label
|
||||
>Auto-hide timer (0.1–15s):<br /><em
|
||||
>Seconds of inactivity before hiding: 0.1–15 for
|
||||
non-YouTube sites.</em
|
||||
></label
|
||||
>
|
||||
<label>Auto-hide timer (0.1–15s):</label>
|
||||
<input type="text" class="site-hideWithControlsTimer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-rule-override-section">
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override playback for this site<br /><em
|
||||
>When on, remember speed / force / audio below replace
|
||||
general defaults for matching URLs.</em
|
||||
></span
|
||||
>
|
||||
<span>Override playback for this site</span>
|
||||
<input type="checkbox" class="override-playback" />
|
||||
</label>
|
||||
<div class="site-playback-container">
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label
|
||||
>Remember playback speed:<br /><em
|
||||
>Stores speed per source so revisiting the same media
|
||||
can restore it. Separate from “Force last saved
|
||||
speed,” which fights players that reset
|
||||
rate.</em
|
||||
></label
|
||||
>
|
||||
<label>Remember playback speed:</label>
|
||||
<input type="checkbox" class="site-rememberSpeed" />
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label
|
||||
>Force last saved speed:<br /><em
|
||||
>Useful when a video player tries to override the speed
|
||||
you set in Speeder.</em
|
||||
></label
|
||||
>
|
||||
<label>Force last saved speed:</label>
|
||||
<input type="checkbox" class="site-forceLastSavedSpeed" />
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label
|
||||
>Work on audio:<br /><em
|
||||
>Also controls plain HTML5 audio (not just video). Turn
|
||||
off if you only want Speeder on video players.</em
|
||||
></label
|
||||
>
|
||||
<label>Work on audio:</label>
|
||||
<input type="checkbox" class="site-audioBoolean" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-rule-override-section">
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override opacity for this site<br /><em
|
||||
>When on, opacity below replaces the general default for
|
||||
matching URLs.</em
|
||||
></span
|
||||
>
|
||||
<span>Override opacity for this site</span>
|
||||
<input type="checkbox" class="override-opacity" />
|
||||
</label>
|
||||
<div class="site-opacity-container">
|
||||
<div class="site-rule-option site-rule-option-field">
|
||||
<label
|
||||
>Controller opacity:<br /><em
|
||||
>0–1 (decimals). Lower is more transparent.
|
||||
Applies to the in-page controller only.</em
|
||||
></label
|
||||
>
|
||||
<label>Controller opacity:</label>
|
||||
<input type="text" class="site-controllerOpacity" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-rule-override-section">
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override subtitle nudge for this site<br /><em
|
||||
>When on, nudge options below replace general defaults for
|
||||
matching URLs.</em
|
||||
></span
|
||||
>
|
||||
<span>Override subtitle nudge for this site</span>
|
||||
<input type="checkbox" class="override-subtitleNudge" />
|
||||
</label>
|
||||
<div class="site-subtitleNudge-container">
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label
|
||||
>Enable subtitle nudge:<br /><em
|
||||
>Makes tiny playback changes to help keep subtitles
|
||||
aligned.</em
|
||||
></label
|
||||
>
|
||||
<label>Enable subtitle nudge:</label>
|
||||
<input type="checkbox" class="site-enableSubtitleNudge" />
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label
|
||||
>Start subtitle nudge enabled:<br /><em
|
||||
>When off, matching videos start with nudging disabled
|
||||
until you toggle it.</em
|
||||
></label
|
||||
>
|
||||
<input type="checkbox" class="site-subtitleNudgeEnabledByDefault" />
|
||||
</div>
|
||||
<div class="site-rule-option site-rule-option-field">
|
||||
<label
|
||||
>Nudge interval (10–1000ms):<br /><em
|
||||
>How often to nudge: 10–1000. Smaller values are
|
||||
more frequent. Default: 50.</em
|
||||
></label
|
||||
>
|
||||
<label>Nudge interval (10–1000ms):</label>
|
||||
<input type="text" class="site-subtitleNudgeInterval" placeholder="50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site-rule-controlbar">
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override in-player control bar for this site<br /><em
|
||||
>Same idea as Hover control bar: drag blocks between
|
||||
Active and Available for matching URLs only.</em
|
||||
></span
|
||||
>
|
||||
<span>Override in-player control bar for this site</span>
|
||||
<input type="checkbox" class="override-controlbar" />
|
||||
</label>
|
||||
<div class="site-controlbar-container">
|
||||
@@ -882,22 +709,12 @@
|
||||
</div>
|
||||
<div class="site-rule-controlbar">
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override extension popup for this site<br /><em
|
||||
>Popup layout for matching URLs; mirrors the global Popup
|
||||
control bar when you customize it here.</em
|
||||
></span
|
||||
>
|
||||
<span>Override extension popup for this site</span>
|
||||
<input type="checkbox" class="override-popup-controlbar" />
|
||||
</label>
|
||||
<div class="site-popup-controlbar-container">
|
||||
<div class="site-rule-option site-rule-option-checkbox">
|
||||
<label
|
||||
>Show popup control bar<br /><em
|
||||
>Shows buttons in the extension popup (toolbar icon).
|
||||
Replaces the general default for this pattern.</em
|
||||
></label
|
||||
>
|
||||
<label>Show popup control bar</label>
|
||||
<input type="checkbox" class="site-showPopupControlBar" />
|
||||
</div>
|
||||
<div class="cb-editor">
|
||||
@@ -914,23 +731,10 @@
|
||||
</div>
|
||||
<div class="site-rule-shortcuts">
|
||||
<label class="site-override-lead">
|
||||
<span
|
||||
>Override shortcuts for this site<br /><em
|
||||
>Add shortcuts from the menu; none by default. Leave off
|
||||
to use global Shortcuts.</em
|
||||
></span
|
||||
>
|
||||
<span>Override shortcuts for this site</span>
|
||||
<input type="checkbox" class="override-shortcuts" />
|
||||
</label>
|
||||
<div class="site-shortcuts-container">
|
||||
<div class="site-shortcuts-rows"></div>
|
||||
<select
|
||||
class="site-add-shortcut-selector"
|
||||
aria-label="Add shortcut for this site"
|
||||
>
|
||||
<option value="">Add shortcut…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="site-shortcuts-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,7 +210,6 @@ var tcDefaults = {
|
||||
popupMatchHoverControls: true,
|
||||
popupControllerButtons: ["rewind", "slower", "faster", "advance", "display"],
|
||||
enableSubtitleNudge: false,
|
||||
subtitleNudgeEnabledByDefault: true,
|
||||
subtitleNudgeInterval: 50,
|
||||
subtitleNudgeAmount: 0.001
|
||||
};
|
||||
@@ -234,7 +233,7 @@ const actionLabels = {
|
||||
};
|
||||
|
||||
const speedBindingActions = ["slower", "faster", "fast", "softer", "louder"];
|
||||
const requiredShortcutActions = new Set(["slower", "faster"]);
|
||||
const requiredShortcutActions = new Set(["display", "slower", "faster"]);
|
||||
|
||||
function formatSpeedBindingDisplay(action, value) {
|
||||
if (!speedBindingActions.includes(action)) {
|
||||
@@ -320,70 +319,6 @@ function refreshAddShortcutSelector() {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSiteRuleAddShortcutSelector(ruleEl) {
|
||||
if (!ruleEl) return;
|
||||
var selector = ruleEl.querySelector(".site-add-shortcut-selector");
|
||||
if (!selector) return;
|
||||
|
||||
while (selector.options.length > 1) {
|
||||
selector.remove(1);
|
||||
}
|
||||
|
||||
var usedActions = new Set();
|
||||
ruleEl.querySelectorAll(".site-shortcuts-rows .shortcut-row.customs").forEach(function (row) {
|
||||
var action = row.dataset.action;
|
||||
if (action) usedActions.add(action);
|
||||
});
|
||||
|
||||
Object.keys(actionLabels).forEach(function (action) {
|
||||
if (!usedActions.has(action)) {
|
||||
var option = document.createElement("option");
|
||||
option.value = action;
|
||||
option.textContent = actionLabels[action];
|
||||
selector.appendChild(option);
|
||||
}
|
||||
});
|
||||
|
||||
var overrideShortcutsOn =
|
||||
ruleEl.querySelector(".override-shortcuts") &&
|
||||
ruleEl.querySelector(".override-shortcuts").checked;
|
||||
|
||||
if (selector.options.length === 1) {
|
||||
selector.disabled = true;
|
||||
selector.options[0].text = "All shortcuts added";
|
||||
} else {
|
||||
selector.disabled = !overrideShortcutsOn;
|
||||
selector.options[0].text = "Add shortcut\u2026";
|
||||
}
|
||||
}
|
||||
|
||||
function getGlobalBindingSnapshotForSiteShortcut(action) {
|
||||
var row = document.querySelector(
|
||||
'#customs .shortcut-row[data-action="' + action + '"]'
|
||||
);
|
||||
if (row) {
|
||||
var keyInput = row.querySelector(".customKey");
|
||||
var binding = normalizeStoredBinding(keyInput && keyInput.vscBinding);
|
||||
if (binding) {
|
||||
var valueInput = row.querySelector(".customValue");
|
||||
var value = customActionsNoValues.includes(action)
|
||||
? 0
|
||||
: Number(valueInput && valueInput.value);
|
||||
return { binding: binding, value: value };
|
||||
}
|
||||
}
|
||||
var def = tcDefaults.keyBindings.find(function (b) {
|
||||
return b.action === action;
|
||||
});
|
||||
if (def) {
|
||||
return {
|
||||
binding: normalizeStoredBinding(def),
|
||||
value: def.value
|
||||
};
|
||||
}
|
||||
return { binding: null, value: undefined };
|
||||
}
|
||||
|
||||
function ensureDefaultBinding(storage, action, code, value) {
|
||||
if (storage.keyBindings.some((item) => item.action === action)) return;
|
||||
|
||||
@@ -824,8 +759,6 @@ function save_options() {
|
||||
settings.keyBindings = keyBindings;
|
||||
settings.enableSubtitleNudge =
|
||||
document.getElementById("enableSubtitleNudge").checked;
|
||||
settings.subtitleNudgeEnabledByDefault =
|
||||
document.getElementById("subtitleNudgeEnabledByDefault").checked;
|
||||
settings.subtitleNudgeInterval =
|
||||
parseInt(document.getElementById("subtitleNudgeInterval").value, 10) ||
|
||||
tcDefaults.subtitleNudgeInterval;
|
||||
@@ -909,8 +842,6 @@ function save_options() {
|
||||
if (ruleEl.querySelector(".override-subtitleNudge").checked) {
|
||||
rule.enableSubtitleNudge =
|
||||
ruleEl.querySelector(".site-enableSubtitleNudge").checked;
|
||||
rule.subtitleNudgeEnabledByDefault =
|
||||
ruleEl.querySelector(".site-subtitleNudgeEnabledByDefault").checked;
|
||||
var nudgeIv = parseInt(
|
||||
ruleEl.querySelector(".site-subtitleNudgeInterval").value,
|
||||
10
|
||||
@@ -1011,18 +942,35 @@ function ensureAllDefaultBindings(storage) {
|
||||
});
|
||||
}
|
||||
|
||||
function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
|
||||
if (!rowsEl) return;
|
||||
|
||||
function addSiteRuleShortcut(container, action, binding, value, force) {
|
||||
var div = document.createElement("div");
|
||||
div.setAttribute("class", "shortcut-row customs");
|
||||
div.dataset.action = action;
|
||||
|
||||
var actionLabel = document.createElement("div");
|
||||
actionLabel.className = "shortcut-label";
|
||||
var actionLabels = {
|
||||
display: "Show/hide controller",
|
||||
move: "Move controller",
|
||||
slower: "Decrease speed",
|
||||
faster: "Increase speed",
|
||||
rewind: "Rewind",
|
||||
advance: "Advance",
|
||||
reset: "Reset speed",
|
||||
fast: "Preferred speed",
|
||||
toggleSubtitleNudge: "Toggle subtitle nudge",
|
||||
pause: "Play / Pause",
|
||||
muted: "Mute / Unmute",
|
||||
louder: "Increase volume",
|
||||
softer: "Decrease volume",
|
||||
mark: "Set marker",
|
||||
jump: "Jump to marker"
|
||||
};
|
||||
var actionLabelText = actionLabels[action] || action;
|
||||
if (action === "toggleSubtitleNudge") {
|
||||
var ruleEl = rowsEl.closest(".site-rule");
|
||||
// Check if the site rule is for YouTube.
|
||||
// We look up the pattern from the site rule element this container belongs to.
|
||||
var ruleEl = container.closest(".site-rule");
|
||||
var pattern = ruleEl ? ruleEl.querySelector(".site-pattern").value : "";
|
||||
if (!pattern.toLowerCase().includes("youtube.com")) {
|
||||
actionLabelText += " (only for YouTube embeds)";
|
||||
@@ -1066,18 +1014,12 @@ function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
|
||||
forceLabel.appendChild(forceCheckbox);
|
||||
forceLabel.appendChild(forceText);
|
||||
|
||||
var removeButton = document.createElement("button");
|
||||
removeButton.className = "removeParent";
|
||||
removeButton.type = "button";
|
||||
removeButton.textContent = "\u00d7";
|
||||
|
||||
div.appendChild(actionLabel);
|
||||
div.appendChild(keyInput);
|
||||
div.appendChild(valueInput);
|
||||
div.appendChild(forceLabel);
|
||||
div.appendChild(removeButton);
|
||||
|
||||
rowsEl.appendChild(div);
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
function createSiteRule(rule) {
|
||||
@@ -1167,12 +1109,10 @@ function createSiteRule(rule) {
|
||||
var hasSubtitleNudgeOverride = Boolean(
|
||||
rule &&
|
||||
(rule.enableSubtitleNudge !== undefined ||
|
||||
rule.subtitleNudgeEnabledByDefault !== undefined ||
|
||||
rule.subtitleNudgeInterval !== undefined)
|
||||
);
|
||||
ruleEl.querySelector(".override-subtitleNudge").checked = hasSubtitleNudgeOverride;
|
||||
syncSiteRuleField(ruleEl, rule, "enableSubtitleNudge", true);
|
||||
syncSiteRuleField(ruleEl, rule, "subtitleNudgeEnabledByDefault", true);
|
||||
syncSiteRuleField(ruleEl, rule, "subtitleNudgeInterval", false);
|
||||
applySiteRuleOverrideState(
|
||||
ruleEl,
|
||||
@@ -1217,24 +1157,56 @@ function createSiteRule(rule) {
|
||||
rule && Array.isArray(rule.shortcuts) && rule.shortcuts.length > 0
|
||||
);
|
||||
ruleEl.querySelector(".override-shortcuts").checked = hasShortcutOverride;
|
||||
var rowsEl = ruleEl.querySelector(".site-shortcuts-rows");
|
||||
var container = ruleEl.querySelector(".site-shortcuts-container");
|
||||
if (hasShortcutOverride) {
|
||||
rule.shortcuts.forEach((shortcut) => {
|
||||
addSiteRuleShortcut(
|
||||
rowsEl,
|
||||
container,
|
||||
shortcut.action,
|
||||
shortcut,
|
||||
shortcut.value,
|
||||
shortcut.force
|
||||
);
|
||||
});
|
||||
} else {
|
||||
populateDefaultSiteShortcuts(container);
|
||||
}
|
||||
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
|
||||
refreshSiteRuleAddShortcutSelector(ruleEl);
|
||||
|
||||
document.getElementById("siteRulesContainer").appendChild(ruleEl);
|
||||
}
|
||||
|
||||
function populateDefaultSiteShortcuts(container) {
|
||||
var bindings = [];
|
||||
document.querySelectorAll("#customs .shortcut-row").forEach((row) => {
|
||||
var action = row.dataset.action;
|
||||
if (!action) return;
|
||||
|
||||
var keyInput = row.querySelector(".customKey");
|
||||
var binding = normalizeStoredBinding(keyInput && keyInput.vscBinding);
|
||||
if (!binding) return;
|
||||
|
||||
var valueInput = row.querySelector(".customValue");
|
||||
bindings.push({
|
||||
action: action,
|
||||
code: binding.code,
|
||||
disabled: binding.disabled === true,
|
||||
value: customActionsNoValues.includes(action)
|
||||
? 0
|
||||
: Number(valueInput && valueInput.value),
|
||||
force: false
|
||||
});
|
||||
});
|
||||
|
||||
if (bindings.length === 0) {
|
||||
bindings = tcDefaults.keyBindings.slice();
|
||||
}
|
||||
|
||||
bindings.forEach((binding) => {
|
||||
addSiteRuleShortcut(container, binding.action, binding, binding.value, false);
|
||||
});
|
||||
}
|
||||
|
||||
function createControlBarBlock(buttonId) {
|
||||
var def = controllerButtonDefs[buttonId];
|
||||
if (!def) return null;
|
||||
@@ -1657,8 +1629,6 @@ function restore_options() {
|
||||
storage.showPopupControlBar !== false;
|
||||
document.getElementById("enableSubtitleNudge").checked =
|
||||
storage.enableSubtitleNudge;
|
||||
document.getElementById("subtitleNudgeEnabledByDefault").checked =
|
||||
storage.subtitleNudgeEnabledByDefault;
|
||||
document.getElementById("subtitleNudgeInterval").value =
|
||||
storage.subtitleNudgeInterval;
|
||||
|
||||
@@ -1810,13 +1780,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
var removeParentButton = targetEl.closest(".removeParent");
|
||||
if (removeParentButton) {
|
||||
var removedRow = removeParentButton.parentNode;
|
||||
var siteRuleForShortcut = removedRow.closest(".site-rule");
|
||||
removedRow.remove();
|
||||
removeParentButton.parentNode.remove();
|
||||
refreshAddShortcutSelector();
|
||||
if (siteRuleForShortcut) {
|
||||
refreshSiteRuleAddShortcutSelector(siteRuleForShortcut);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
|
||||
@@ -1843,26 +1808,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.target.classList.contains("site-add-shortcut-selector")) {
|
||||
var action = event.target.value;
|
||||
if (!action) return;
|
||||
var siteRuleRoot = event.target.closest(".site-rule");
|
||||
var rows = siteRuleRoot && siteRuleRoot.querySelector(".site-shortcuts-rows");
|
||||
if (rows) {
|
||||
var snap = getGlobalBindingSnapshotForSiteShortcut(action);
|
||||
addSiteRuleShortcut(
|
||||
rows,
|
||||
action,
|
||||
snap.binding,
|
||||
snap.value,
|
||||
false
|
||||
);
|
||||
refreshSiteRuleAddShortcutSelector(siteRuleRoot);
|
||||
}
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Site rule: show/hide optional override sections
|
||||
var siteOverrideContainers = {
|
||||
"override-placement": "site-placement-container",
|
||||
@@ -1884,9 +1829,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (targetBox) {
|
||||
setSiteOverrideContainerState(targetBox, event.target.checked);
|
||||
}
|
||||
if (ocb === "override-shortcuts") {
|
||||
refreshSiteRuleAddShortcutSelector(siteRuleRoot);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -94,24 +94,10 @@ button:focus-visible {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#refresh:hover {
|
||||
#refresh:hover {
|
||||
background: #1f2937;
|
||||
border-color: #1f2937;
|
||||
}
|
||||
|
||||
.popup-compact-action {
|
||||
min-height: 24px;
|
||||
padding: 0 9px;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.popup-compact-action[aria-pressed="true"] {
|
||||
background: #e8f3e5;
|
||||
border-color: #9fbd98;
|
||||
color: #285d21;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-divider {
|
||||
height: 1px;
|
||||
@@ -224,63 +210,21 @@ button:focus-visible {
|
||||
|
||||
.donate-split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.donate-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
padding: 6px 4px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.donate-icon-btn:hover {
|
||||
background: #f8f9fb;
|
||||
border-color: #c5ccd5;
|
||||
}
|
||||
|
||||
.donate-icon-btn:active {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.donate-icon-btn:focus-visible {
|
||||
outline: 2px solid rgba(17, 24, 39, 0.14);
|
||||
outline-offset: 2px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.donate-icon-btn svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.donate-icon-btn--kofi img {
|
||||
display: block;
|
||||
height: 22px;
|
||||
.donate-split button {
|
||||
width: auto;
|
||||
max-width: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.donate-icon-btn:first-child {
|
||||
border-radius: 8px 0 0 8px;
|
||||
border-right-width: 0;
|
||||
}
|
||||
|
||||
.donate-icon-btn:nth-child(2) {
|
||||
border-radius: 0;
|
||||
border-right-width: 0;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.donate-icon-btn:last-child {
|
||||
.donate-split button:first-child {
|
||||
border-radius: 8px 0 0 8px;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.donate-split button:last-child {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
@@ -318,23 +262,8 @@ button:focus-visible {
|
||||
color: #111315;
|
||||
}
|
||||
|
||||
#refresh:hover {
|
||||
#refresh:hover {
|
||||
background: #dfe3e8;
|
||||
border-color: #dfe3e8;
|
||||
}
|
||||
|
||||
.popup-compact-action[aria-pressed="true"] {
|
||||
background: #21351f;
|
||||
border-color: #52724d;
|
||||
color: #b8ddb1;
|
||||
}
|
||||
|
||||
.donate-icon-btn:hover {
|
||||
background: #1f2226;
|
||||
border-color: #4a515a;
|
||||
}
|
||||
|
||||
.donate-icon-btn:active {
|
||||
background: #252a2f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Speeder</title>
|
||||
<link rel="stylesheet" href="popup.css" />
|
||||
<script src="shared/site-rules.js"></script>
|
||||
<script src="shared/popup-controls.js"></script>
|
||||
<script src="ui-icons.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup-shell">
|
||||
<div class="popup-header">
|
||||
<span class="popup-title">Speeder</span>
|
||||
<span class="popup-version">v<span id="app-version"></span></span>
|
||||
</div>
|
||||
<div class="popup-actions">
|
||||
<button id="refresh">Rescan page for videos</button>
|
||||
<div class="popup-divider"></div>
|
||||
<div id="popupControlBar" class="popup-control-bar">
|
||||
<span id="popupSpeed" class="popup-speed">1.00</span>
|
||||
</div>
|
||||
<div class="popup-divider"></div>
|
||||
<button id="enable" class="hide">Enable</button>
|
||||
<button id="disable">Disable</button>
|
||||
</div>
|
||||
<div id="status" class="popup-status hide"></div>
|
||||
<div class="popup-links">
|
||||
<button id="config">Settings</button>
|
||||
<div class="popup-secondary">
|
||||
<button id="feedback" class="secondary">Feedback</button>
|
||||
<button id="about" class="secondary">About</button>
|
||||
</div>
|
||||
<div id="donateWrap" class="donate-wrap">
|
||||
<button id="donate" class="secondary">Donate</button>
|
||||
<div id="donateOptions" class="donate-split hide">
|
||||
<button id="donateKofi" class="secondary">Ko-fi</button>
|
||||
<button id="donateGithub" class="secondary">Sponsors</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,7 +4,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
var siteRuleUtils = speederShared.siteRules || {};
|
||||
var popupControlUtils = speederShared.popupControls || {};
|
||||
|
||||
/* `label` is only used if shared/ui-icons.js has no path for this action (fallback). */
|
||||
/* `label` is only used if ui-icons.js has no path for this action (fallback). */
|
||||
var controllerButtonDefs = {
|
||||
rewind: { label: "", className: "rw" },
|
||||
slower: { label: "", className: "" },
|
||||
@@ -27,8 +27,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
var popupExcludedButtonIds = new Set(["settings"]);
|
||||
var storageDefaults = {
|
||||
enabled: true,
|
||||
lastSpeed: 1.0,
|
||||
forceLastSavedSpeed: false,
|
||||
showPopupControlBar: true,
|
||||
controllerButtons: defaultButtons,
|
||||
popupMatchHoverControls: true,
|
||||
@@ -37,15 +35,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
};
|
||||
var renderToken = 0;
|
||||
|
||||
function updateForceButton(enabled) {
|
||||
var button = document.getElementById("forceLastSavedSpeed");
|
||||
if (!button) return;
|
||||
button.setAttribute("aria-pressed", enabled ? "true" : "false");
|
||||
button.title = enabled
|
||||
? "Stop forcing the saved speed"
|
||||
: "Keep this page at the last speed saved by Speeder";
|
||||
}
|
||||
|
||||
function matchSiteRule(url, siteRules) {
|
||||
return siteRuleUtils.matchSiteRule(url, siteRules);
|
||||
}
|
||||
@@ -139,7 +128,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
var tabId = tabs[0].id;
|
||||
chrome.tabs.executeScript(
|
||||
tabId,
|
||||
{ allFrames: true, file: "content/frame-speed-snapshot.js" },
|
||||
{ allFrames: true, file: "frameSpeedSnapshot.js" },
|
||||
function (results) {
|
||||
if (chrome.runtime.lastError) {
|
||||
sendToActiveTab({ action: "get_speed" }, function (response) {
|
||||
@@ -203,7 +192,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
|
||||
btn.addEventListener("click", function () {
|
||||
if (btnId === "settings") {
|
||||
window.open(chrome.runtime.getURL("options/options.html"));
|
||||
window.open(chrome.runtime.getURL("options.html"));
|
||||
return;
|
||||
}
|
||||
sendToActiveTab(
|
||||
@@ -221,11 +210,11 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
var manifest = chrome.runtime.getManifest();
|
||||
var versionElement = document.querySelector("#app-version");
|
||||
if (versionElement) {
|
||||
versionElement.textContent = manifest.version;
|
||||
versionElement.innerText = manifest.version;
|
||||
}
|
||||
|
||||
document.querySelector("#config").addEventListener("click", function () {
|
||||
window.open(chrome.runtime.getURL("options/options.html"));
|
||||
window.open(chrome.runtime.getURL("options.html"));
|
||||
});
|
||||
|
||||
document.querySelector("#about").addEventListener("click", function () {
|
||||
@@ -241,6 +230,14 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelector("#donateOptions").classList.remove("hide");
|
||||
});
|
||||
|
||||
document.querySelector("#donateKofi").addEventListener("click", function () {
|
||||
window.open("https://ko-fi.com/joshpatra");
|
||||
});
|
||||
|
||||
document.querySelector("#donateGithub").addEventListener("click", function () {
|
||||
window.open("https://github.com/sponsors/SoPat712");
|
||||
});
|
||||
|
||||
document.querySelector("#enable").addEventListener("click", function () {
|
||||
toggleEnabled(true, settingsSavedReloadMessage);
|
||||
});
|
||||
@@ -263,43 +260,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
});
|
||||
});
|
||||
|
||||
var forceLastSavedSpeedButton = document.querySelector(
|
||||
"#forceLastSavedSpeed"
|
||||
);
|
||||
if (!forceLastSavedSpeedButton.dataset.listenerAttached) {
|
||||
forceLastSavedSpeedButton.dataset.listenerAttached = "true";
|
||||
forceLastSavedSpeedButton.addEventListener("click", function () {
|
||||
var button = this;
|
||||
var enabled = button.getAttribute("aria-pressed") !== "true";
|
||||
chrome.storage.sync.get({ lastSpeed: 1.0 }, function (storage) {
|
||||
chrome.storage.sync.set({ forceLastSavedSpeed: enabled }, function () {
|
||||
updateForceButton(enabled);
|
||||
sendToActiveTab(
|
||||
{
|
||||
action: "set_force_last_saved_speed",
|
||||
enabled: enabled,
|
||||
speed: Number(storage.lastSpeed) || 1.0
|
||||
},
|
||||
function (response) {
|
||||
if (response && response.speed != null) {
|
||||
updateSpeedDisplay(response.speed);
|
||||
setStatusMessage(
|
||||
enabled ? "Saved speed is now forced." : "Speed forcing is off."
|
||||
);
|
||||
} else {
|
||||
setStatusMessage(
|
||||
enabled
|
||||
? "Force enabled. No video found on this page."
|
||||
: "Speed forcing is off."
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderForActiveTab() {
|
||||
var currentRenderToken = ++renderToken;
|
||||
|
||||
@@ -319,10 +279,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
var url = context && context.url ? context.url : "";
|
||||
var siteRule = matchSiteRule(url, storage.siteRules);
|
||||
var siteDisabled = isSiteRuleDisabled(siteRule);
|
||||
var siteAvailable = siteRuleUtils.isSpeederActiveForSite(
|
||||
storage.enabled,
|
||||
siteRule
|
||||
);
|
||||
var siteAvailable = storage.enabled !== false && !siteDisabled;
|
||||
var showBar = storage.showPopupControlBar !== false;
|
||||
|
||||
if (siteRule && siteRule.showPopupControlBar !== undefined) {
|
||||
@@ -330,7 +287,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
|
||||
toggleEnabledUI(storage.enabled !== false);
|
||||
updateForceButton(storage.forceLastSavedSpeed === true);
|
||||
buildControlBar(
|
||||
resolvePopupButtons(storage, siteRule),
|
||||
customIconsMap
|
||||
@@ -373,7 +329,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (areaName !== "sync") return;
|
||||
if (
|
||||
changes.enabled ||
|
||||
changes.forceLastSavedSpeed ||
|
||||
changes.showPopupControlBar ||
|
||||
changes.controllerButtons ||
|
||||
changes.popupMatchHoverControls ||
|
||||
@@ -400,9 +355,9 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
const suffix = `${enabled ? "" : "_disabled"}.png`;
|
||||
chrome.browserAction.setIcon({
|
||||
path: {
|
||||
19: "assets/icons/icon19" + suffix,
|
||||
38: "assets/icons/icon38" + suffix,
|
||||
48: "assets/icons/icon48" + suffix
|
||||
19: "icons/icon19" + suffix,
|
||||
38: "icons/icon38" + suffix,
|
||||
48: "icons/icon48" + suffix
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -416,12 +371,12 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
function setStatusMessage(str) {
|
||||
const status_element = document.querySelector("#status");
|
||||
status_element.classList.toggle("hide", false);
|
||||
status_element.textContent = str;
|
||||
status_element.innerText = str;
|
||||
}
|
||||
|
||||
function clearStatusMessage() {
|
||||
const status_element = document.querySelector("#status");
|
||||
status_element.classList.toggle("hide", true);
|
||||
status_element.textContent = "";
|
||||
status_element.innerText = "";
|
||||
}
|
||||
});
|
||||
@@ -7,20 +7,19 @@ set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT"
|
||||
MANIFEST_PATH="extension/manifest.json"
|
||||
|
||||
manifest_version() {
|
||||
MANIFEST_PATH="$MANIFEST_PATH" python3 -c 'import json, os; print(json.load(open(os.environ["MANIFEST_PATH"]))["version"])'
|
||||
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
|
||||
}
|
||||
|
||||
bump_manifest() {
|
||||
local ver="$1"
|
||||
VER="$ver" MANIFEST_PATH="$MANIFEST_PATH" python3 <<'PY'
|
||||
VER="$ver" python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
ver = os.environ["VER"]
|
||||
path = os.environ["MANIFEST_PATH"]
|
||||
path = "manifest.json"
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data["version"] = ver
|
||||
@@ -59,8 +58,8 @@ fi
|
||||
git checkout beta
|
||||
git pull origin beta
|
||||
|
||||
echo "Current version on beta ($MANIFEST_PATH): $(manifest_version)"
|
||||
read -r -p "Release version for $MANIFEST_PATH + tag (e.g. 5.0.4): " SEMVER_IN
|
||||
echo "Current version on beta (manifest.json): $(manifest_version)"
|
||||
read -r -p "Release version for manifest.json + tag (e.g. 5.0.4): " SEMVER_IN
|
||||
SEMVER="$(normalize_semver "$SEMVER_IN")"
|
||||
validate_semver "$SEMVER"
|
||||
|
||||
@@ -74,7 +73,7 @@ fi
|
||||
echo
|
||||
echo "This will:"
|
||||
echo " 1. checkout main, merge --squash origin/beta (single release commit on main)"
|
||||
echo " 2. set $MANIFEST_PATH to $SEMVER in that commit (if anything else changed, it is included too)"
|
||||
echo " 2. set manifest.json to $SEMVER in that commit (if anything else changed, it is included too)"
|
||||
echo " 3. push origin main, create tag $TAG, push tag (triggers listed AMO submit)"
|
||||
echo " 4. checkout dev (merge main→dev yourself if you want them aligned)"
|
||||
read -r -p "Continue? [y/N] " confirm
|
||||
|
||||
@@ -6,20 +6,19 @@ set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT"
|
||||
MANIFEST_PATH="extension/manifest.json"
|
||||
|
||||
manifest_version() {
|
||||
MANIFEST_PATH="$MANIFEST_PATH" python3 -c 'import json, os; print(json.load(open(os.environ["MANIFEST_PATH"]))["version"])'
|
||||
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
|
||||
}
|
||||
|
||||
bump_manifest() {
|
||||
local ver="$1"
|
||||
VER="$ver" MANIFEST_PATH="$MANIFEST_PATH" python3 <<'PY'
|
||||
VER="$ver" python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
ver = os.environ["VER"]
|
||||
path = os.environ["MANIFEST_PATH"]
|
||||
path = "manifest.json"
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data["version"] = ver
|
||||
@@ -58,8 +57,8 @@ fi
|
||||
git checkout dev
|
||||
git pull origin dev
|
||||
|
||||
echo "Current version in $MANIFEST_PATH: $(manifest_version)"
|
||||
read -r -p "New version for $MANIFEST_PATH (e.g. 5.0.4): " SEMVER_IN
|
||||
echo "Current version in manifest.json: $(manifest_version)"
|
||||
read -r -p "New version for manifest.json (e.g. 5.0.4): " SEMVER_IN
|
||||
SEMVER="$(normalize_semver "$SEMVER_IN")"
|
||||
validate_semver "$SEMVER"
|
||||
|
||||
@@ -77,7 +76,7 @@ fi
|
||||
|
||||
echo
|
||||
echo "This will:"
|
||||
echo " 1. set $MANIFEST_PATH version to $SEMVER, commit on dev, push origin dev"
|
||||
echo " 1. set manifest.json version to $SEMVER, commit on dev, push origin dev"
|
||||
echo " 2. checkout beta, merge dev (no-ff), push origin beta"
|
||||
echo " 3. create tag $TAG and push it (triggers beta AMO + prerelease)"
|
||||
echo " 4. checkout dev (main is not modified)"
|
||||
@@ -87,7 +86,7 @@ read -r -p "Continue? [y/N] " confirm
|
||||
echo "🚀 Releasing beta $TAG"
|
||||
|
||||
bump_manifest "$SEMVER"
|
||||
git add "$MANIFEST_PATH"
|
||||
git add manifest.json
|
||||
git commit -m "Bump version to $SEMVER"
|
||||
git push origin dev
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"audioBoolean",
|
||||
"controllerOpacity",
|
||||
"enableSubtitleNudge",
|
||||
"subtitleNudgeEnabledByDefault",
|
||||
"subtitleNudgeInterval",
|
||||
"controllerButtons",
|
||||
"showPopupControlBar",
|
||||
@@ -44,7 +43,6 @@
|
||||
"popupMatchHoverControls",
|
||||
"popupControllerButtons",
|
||||
"enableSubtitleNudge",
|
||||
"subtitleNudgeEnabledByDefault",
|
||||
"subtitleNudgeInterval",
|
||||
"subtitleNudgeAmount"
|
||||
];
|
||||
@@ -182,7 +180,6 @@
|
||||
popupMatchHoverControls: true,
|
||||
popupControllerButtons: DEFAULT_BUTTONS.slice(),
|
||||
enableSubtitleNudge: false,
|
||||
subtitleNudgeEnabledByDefault: true,
|
||||
subtitleNudgeInterval: 50,
|
||||
subtitleNudgeAmount: 0.001
|
||||
};
|
||||
@@ -1,33 +1,3 @@
|
||||
:host {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
pointer-events: none !important;
|
||||
z-index: 2147483647 !important;
|
||||
white-space: normal;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
:host(.vsc-nosource),
|
||||
:host(.vsc-hidden) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:host(.ytp-autohide:not(.vsc-hidden)),
|
||||
:host(.vsc-idle-hidden:not(.vsc-hidden)) {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:host(.ytp-autohide.vsc-show:not(.vsc-hidden)),
|
||||
:host(.vsc-idle-hidden.vsc-show:not(.vsc-hidden)),
|
||||
:host(.vsc-forced-show:not(.vsc-hidden)) {
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
* {
|
||||
line-height: 1.9em;
|
||||
font-family: sans-serif;
|
||||
@@ -33,7 +33,6 @@
|
||||
"speed",
|
||||
"startHidden",
|
||||
"subtitleNudgeAmount",
|
||||
"subtitleNudgeEnabledByDefault",
|
||||
"subtitleNudgeInterval"
|
||||
]);
|
||||
|
||||
@@ -50,7 +49,7 @@
|
||||
/**
|
||||
* Local-only keys excluded from backup JSON. These are disposable caches
|
||||
* (e.g. Lucide tags.json) that bloat exports and are refetched when needed.
|
||||
* Keep in sync with options/lucide-client.js (LUCIDE_TAGS_CACHE_KEY + "At").
|
||||
* Keep in sync with lucide-client.js (LUCIDE_TAGS_CACHE_KEY + "At").
|
||||
*/
|
||||
var localSettingsKeysOmittedFromExport = [
|
||||
"lucideTagsCacheV1",
|
||||
@@ -60,28 +60,10 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Speeder should run on this URL given global enabled and the matched rule (if any).
|
||||
* - No rule: follows global (enabled unless explicitly false).
|
||||
* - Rule with site "off" / disableExtension: always inactive (blacklist).
|
||||
* - Rule with site "on": active even when global is off (whitelist).
|
||||
*/
|
||||
function isSpeederActiveForSite(globalEnabled, siteRule) {
|
||||
var globalOn = globalEnabled !== false;
|
||||
if (!siteRule) {
|
||||
return globalOn;
|
||||
}
|
||||
if (isSiteRuleDisabled(siteRule)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
compileSiteRulePattern: compileSiteRulePattern,
|
||||
escapeStringRegExp: escapeStringRegExp,
|
||||
isSiteRuleDisabled: isSiteRuleDisabled,
|
||||
isSpeederActiveForSite: isSpeederActiveForSite,
|
||||
matchSiteRule: matchSiteRule
|
||||
};
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { JSDOM } = require("jsdom");
|
||||
const vi = globalThis.vi;
|
||||
const { vi } = require("vitest");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
@@ -118,11 +118,7 @@ async function flushAsyncWork(turns) {
|
||||
const count = turns || 2;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await Promise.resolve();
|
||||
if (vi && typeof vi.isFakeTimers === "function" && vi.isFakeTimers()) {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@ import {
|
||||
} from "./helpers/browser.js";
|
||||
|
||||
async function setupImportExport(overrides = {}) {
|
||||
loadHtml("extension/options/options.html");
|
||||
loadHtml("options.html");
|
||||
globalThis.chrome = createChromeMock(overrides);
|
||||
window.chrome = globalThis.chrome;
|
||||
const restoreSpy = vi.fn();
|
||||
globalThis.restore_options = restoreSpy;
|
||||
window.restore_options = restoreSpy;
|
||||
loadScript("extension/shared/import-export.js");
|
||||
loadScript("extension/options/import-export.js");
|
||||
loadScript("shared/import-export.js");
|
||||
loadScript("importExport.js");
|
||||
await flushAsyncWork();
|
||||
return globalThis.chrome;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
|
||||
const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
flushAsyncWork,
|
||||
fireDOMContentLoaded,
|
||||
installCommonWindowMocks,
|
||||
loadHtmlString
|
||||
} = require("./helpers/extension-test-utils");
|
||||
@@ -26,38 +26,18 @@ function bootImportExport(options) {
|
||||
global.chrome = chrome;
|
||||
window.chrome = chrome;
|
||||
|
||||
class TestBlob {
|
||||
constructor(parts, options) {
|
||||
this.parts = parts;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async text() {
|
||||
return this.parts.join("");
|
||||
}
|
||||
}
|
||||
global.Blob = TestBlob;
|
||||
window.Blob = TestBlob;
|
||||
|
||||
const createObjectURL = vi.fn(() => "blob:test");
|
||||
const revokeObjectURL = vi.fn();
|
||||
Object.defineProperty(window.URL, "createObjectURL", {
|
||||
configurable: true,
|
||||
value: createObjectURL
|
||||
vi.stubGlobal("URL", {
|
||||
createObjectURL,
|
||||
revokeObjectURL
|
||||
});
|
||||
Object.defineProperty(window.URL, "revokeObjectURL", {
|
||||
configurable: true,
|
||||
value: revokeObjectURL
|
||||
});
|
||||
global.URL = window.URL;
|
||||
|
||||
evaluateScript("extension/shared/import-export.js");
|
||||
evaluateScript("extension/options/import-export.js");
|
||||
fireDOMContentLoaded();
|
||||
evaluateScript("importExport.js");
|
||||
return { chrome, createObjectURL, revokeObjectURL };
|
||||
}
|
||||
|
||||
describe("options/import-export.js", () => {
|
||||
describe("importExport.js", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
@@ -73,15 +53,14 @@ describe("options/import-export.js", () => {
|
||||
bootImportExport();
|
||||
|
||||
expect(window.generateBackupFilename()).toBe(
|
||||
"speeder-backup_2026-04-04_09.14.15.json"
|
||||
"speeder-backup_2026-04-04_13.14.15.json"
|
||||
);
|
||||
});
|
||||
|
||||
it("exports sync and local settings into a downloadable backup", async () => {
|
||||
Object.defineProperty(window.HTMLAnchorElement.prototype, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn()
|
||||
});
|
||||
const clickSpy = vi
|
||||
.spyOn(window.HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
const { createObjectURL, revokeObjectURL } = bootImportExport({
|
||||
syncData: {
|
||||
rememberSpeed: true,
|
||||
@@ -95,6 +74,7 @@ describe("options/import-export.js", () => {
|
||||
});
|
||||
|
||||
document.querySelector("#exportSettings").click();
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||
const blob = createObjectURL.mock.calls[0][0];
|
||||
@@ -102,15 +82,15 @@ describe("options/import-export.js", () => {
|
||||
|
||||
expect(backup.settings.rememberSpeed).toBe(true);
|
||||
expect(backup.localSettings.customButtonIcons.faster.slug).toBe("rocket");
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test");
|
||||
expect(document.querySelector("#status").textContent).toContain("exported");
|
||||
});
|
||||
|
||||
it("omits Lucide tags cache from exported localSettings", async () => {
|
||||
Object.defineProperty(window.HTMLAnchorElement.prototype, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn()
|
||||
});
|
||||
vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation(
|
||||
() => {}
|
||||
);
|
||||
const { createObjectURL } = bootImportExport({
|
||||
syncData: { rememberSpeed: true },
|
||||
localData: {
|
||||
@@ -123,6 +103,7 @@ describe("options/import-export.js", () => {
|
||||
});
|
||||
|
||||
document.querySelector("#exportSettings").click();
|
||||
await flushAsyncWork();
|
||||
|
||||
const blob = createObjectURL.mock.calls[0][0];
|
||||
const backup = JSON.parse(await blob.text());
|
||||
@@ -178,7 +159,6 @@ describe("options/import-export.js", () => {
|
||||
}
|
||||
|
||||
vi.stubGlobal("FileReader", FakeFileReader);
|
||||
window.FileReader = FakeFileReader;
|
||||
|
||||
document.querySelector("#importSettings").click();
|
||||
await flushAsyncWork();
|
||||
@@ -228,7 +208,6 @@ describe("options/import-export.js", () => {
|
||||
}
|
||||
|
||||
vi.stubGlobal("FileReader", FakeFileReader);
|
||||
window.FileReader = FakeFileReader;
|
||||
|
||||
document.querySelector("#importSettings").click();
|
||||
await flushAsyncWork();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { afterEach, describe, expect, it, vi } = require("vitest");
|
||||
const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
@@ -30,11 +31,8 @@ function bootInject(options) {
|
||||
);
|
||||
window.cancelIdleCallback = (id) => clearTimeout(id);
|
||||
|
||||
evaluateScript("extension/shared/controller-utils.js");
|
||||
evaluateScript("extension/shared/key-bindings.js");
|
||||
evaluateScript("extension/shared/site-rules.js");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/content/inject.js");
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("inject.js");
|
||||
|
||||
return chrome;
|
||||
}
|
||||
@@ -118,14 +116,14 @@ describe("inject.js helper logic", () => {
|
||||
bootInject();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
window.tc.settings.siteRules = [{ pattern: "example.org", enabled: false }];
|
||||
window.tc.settings.siteRules = [{ pattern: "localhost", enabled: false }];
|
||||
window.captureSiteRuleBase();
|
||||
expect(window.applySiteRuleOverrides()).toBe(true);
|
||||
|
||||
window.resetSettingsFromSiteRuleBase();
|
||||
window.tc.settings.siteRules = [
|
||||
{
|
||||
pattern: "example.org",
|
||||
pattern: "localhost",
|
||||
controllerLocation: "bottom-left",
|
||||
controllerMarginTop: 300,
|
||||
controllerMarginBottom: -10,
|
||||
@@ -140,180 +138,4 @@ describe("inject.js helper logic", () => {
|
||||
expect(window.tc.settings.controllerMarginBottom).toBe(0);
|
||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||
});
|
||||
|
||||
it("sizes and positions the controller host to the video bounds", async () => {
|
||||
bootInject();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
const mount = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "vsc-controller";
|
||||
mount.append(video, wrapper);
|
||||
document.body.appendChild(mount);
|
||||
|
||||
Object.defineProperties(mount, {
|
||||
offsetWidth: { value: 400 },
|
||||
offsetHeight: { value: 240 },
|
||||
clientLeft: { value: 2 },
|
||||
clientTop: { value: 2 }
|
||||
});
|
||||
mount.getBoundingClientRect = () => ({
|
||||
left: 100,
|
||||
top: 50,
|
||||
right: 500,
|
||||
bottom: 290,
|
||||
width: 400,
|
||||
height: 240
|
||||
});
|
||||
video.getBoundingClientRect = () => ({
|
||||
left: 140,
|
||||
top: 70,
|
||||
right: 460,
|
||||
bottom: 250,
|
||||
width: 320,
|
||||
height: 180
|
||||
});
|
||||
|
||||
window.positionControllerHost(wrapper, video, mount);
|
||||
|
||||
expect(wrapper.style.getPropertyValue("left")).toBe("38px");
|
||||
expect(wrapper.style.getPropertyValue("top")).toBe("18px");
|
||||
expect(wrapper.style.getPropertyValue("width")).toBe("320px");
|
||||
expect(wrapper.style.getPropertyValue("height")).toBe("180px");
|
||||
expect(wrapper.style.getPropertyPriority("width")).toBe("important");
|
||||
});
|
||||
|
||||
it("does not mount the controller outside a player stacking boundary", async () => {
|
||||
bootInject();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
const page = document.createElement("main");
|
||||
const outsidePlayer = document.createElement("section");
|
||||
const isolatedPlayer = document.createElement("div");
|
||||
const videoParent = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
isolatedPlayer.style.isolation = "isolate";
|
||||
videoParent.appendChild(video);
|
||||
isolatedPlayer.appendChild(videoParent);
|
||||
outsidePlayer.appendChild(isolatedPlayer);
|
||||
page.appendChild(outsidePlayer);
|
||||
document.body.appendChild(page);
|
||||
|
||||
const rect = {
|
||||
left: 10,
|
||||
top: 100,
|
||||
right: 650,
|
||||
bottom: 460,
|
||||
width: 640,
|
||||
height: 360
|
||||
};
|
||||
[video, videoParent, isolatedPlayer, outsidePlayer].forEach((element) => {
|
||||
element.getBoundingClientRect = () => rect;
|
||||
});
|
||||
|
||||
expect(window.getControllerMount(video)).toBe(isolatedPlayer);
|
||||
});
|
||||
|
||||
it("keeps the controller inside a nested fullscreen player subtree", async () => {
|
||||
bootInject();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
const isolatedPlayer = document.createElement("div");
|
||||
isolatedPlayer.style.isolation = "isolate";
|
||||
const fullscreenPlayer = document.createElement("media-player");
|
||||
const provider = document.createElement("media-provider");
|
||||
const video = document.createElement("video");
|
||||
provider.appendChild(video);
|
||||
fullscreenPlayer.appendChild(provider);
|
||||
isolatedPlayer.appendChild(fullscreenPlayer);
|
||||
document.body.appendChild(isolatedPlayer);
|
||||
|
||||
const rect = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 640,
|
||||
bottom: 360,
|
||||
width: 640,
|
||||
height: 360
|
||||
};
|
||||
[video, provider, fullscreenPlayer, isolatedPlayer].forEach((element) => {
|
||||
element.getBoundingClientRect = () => rect;
|
||||
});
|
||||
|
||||
expect(window.getControllerMount(video)).toBe(isolatedPlayer);
|
||||
expect(window.getControllerMount(video, fullscreenPlayer)).toBe(
|
||||
fullscreenPlayer
|
||||
);
|
||||
});
|
||||
|
||||
it("remounts the controller on fullscreen entry and restores it on exit", async () => {
|
||||
bootInject();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
const isolatedPlayer = document.createElement("div");
|
||||
isolatedPlayer.style.isolation = "isolate";
|
||||
const fullscreenPlayer = document.createElement("media-player");
|
||||
const provider = document.createElement("media-provider");
|
||||
const video = document.createElement("video");
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "vsc-controller";
|
||||
provider.appendChild(video);
|
||||
fullscreenPlayer.appendChild(provider);
|
||||
isolatedPlayer.append(fullscreenPlayer, wrapper);
|
||||
document.body.appendChild(isolatedPlayer);
|
||||
|
||||
const rect = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 640,
|
||||
bottom: 360,
|
||||
width: 640,
|
||||
height: 360
|
||||
};
|
||||
[video, provider, fullscreenPlayer, isolatedPlayer].forEach((element) => {
|
||||
element.getBoundingClientRect = () => rect;
|
||||
});
|
||||
|
||||
const controller = {
|
||||
video,
|
||||
div: wrapper,
|
||||
normalControllerMount: isolatedPlayer
|
||||
};
|
||||
window.setupControllerHostTracking(controller, wrapper, isolatedPlayer);
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: fullscreenPlayer
|
||||
});
|
||||
|
||||
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
|
||||
expect(wrapper.parentElement).toBe(fullscreenPlayer);
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: null
|
||||
});
|
||||
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
|
||||
expect(wrapper.parentElement).toBe(isolatedPlayer);
|
||||
|
||||
wrapper.remove();
|
||||
controller.controllerHostCleanup();
|
||||
});
|
||||
|
||||
it("force-rescans media missed during initial hydration", async () => {
|
||||
bootInject();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
const video = document.createElement("video");
|
||||
const source = document.createElement("source");
|
||||
source.src = "https://example.org/late-source.mp4";
|
||||
video.appendChild(source);
|
||||
document.body.appendChild(video);
|
||||
|
||||
expect(video.vsc).toBeUndefined();
|
||||
window.initializeWhenReady(document, true);
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,11 +38,11 @@ async function bootInject({ sync = {}, local = {} } = {}) {
|
||||
);
|
||||
globalThis.cancelIdleCallback = (id) => clearTimeout(id);
|
||||
|
||||
loadScript("extension/shared/controller-utils.js");
|
||||
loadScript("extension/shared/key-bindings.js");
|
||||
loadScript("extension/shared/site-rules.js");
|
||||
loadScript("extension/shared/ui-icons.js");
|
||||
loadScript("extension/content/inject.js");
|
||||
loadScript("shared/controller-utils.js");
|
||||
loadScript("shared/key-bindings.js");
|
||||
loadScript("shared/site-rules.js");
|
||||
loadScript("ui-icons.js");
|
||||
loadScript("inject.js");
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await flushAsyncWork();
|
||||
@@ -51,28 +51,6 @@ async function bootInject({ sync = {}, local = {} } = {}) {
|
||||
}
|
||||
|
||||
describe("inject runtime", () => {
|
||||
it("treats a matching site rule with site enabled as active when global enable is off", async () => {
|
||||
await bootInject({
|
||||
sync: {
|
||||
enabled: false,
|
||||
siteRules: [{ pattern: "example.org", enabled: true }]
|
||||
}
|
||||
});
|
||||
|
||||
expect(window.tc.settings.enabled).toBe(false);
|
||||
window.captureSiteRuleBase();
|
||||
window.applySiteRuleOverrides();
|
||||
expect(window.tc.activeSiteRule).toEqual(
|
||||
expect.objectContaining({ pattern: "example.org", enabled: true })
|
||||
);
|
||||
expect(
|
||||
window.SpeederShared.siteRules.isSpeederActiveForSite(
|
||||
window.tc.settings.enabled,
|
||||
window.tc.activeSiteRule
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps subtitle nudge disabled when the effective setting is off", async () => {
|
||||
await bootInject({
|
||||
sync: {
|
||||
@@ -112,154 +90,4 @@ describe("inject runtime", () => {
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
it("uses the configured subtitle nudge default until a video is toggled", async () => {
|
||||
await bootInject({
|
||||
sync: {
|
||||
enableSubtitleNudge: true,
|
||||
subtitleNudgeEnabledByDefault: false
|
||||
}
|
||||
});
|
||||
|
||||
const startSubtitleNudge = vi.fn();
|
||||
const video = {
|
||||
paused: false,
|
||||
playbackRate: 1.5,
|
||||
vsc: {
|
||||
startSubtitleNudge,
|
||||
stopSubtitleNudge: vi.fn(),
|
||||
subtitleNudgeEnabledOverride: null,
|
||||
subtitleNudgeIndicator: null,
|
||||
nudgeFlashIndicator: document.createElement("span")
|
||||
}
|
||||
};
|
||||
|
||||
expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(false);
|
||||
expect(window.setSubtitleNudgeEnabledForVideo(video, true)).toBe(true);
|
||||
expect(video.vsc.subtitleNudgeEnabledOverride).toBe(true);
|
||||
expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(true);
|
||||
expect(startSubtitleNudge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies subtitle nudge default state from matching site rules", async () => {
|
||||
await bootInject();
|
||||
|
||||
window.tc.settings.enableSubtitleNudge = true;
|
||||
window.tc.settings.subtitleNudgeEnabledByDefault = true;
|
||||
window.tc.settings.siteRules = [
|
||||
{
|
||||
pattern: "example.org",
|
||||
subtitleNudgeEnabledByDefault: false
|
||||
}
|
||||
];
|
||||
window.captureSiteRuleBase();
|
||||
|
||||
expect(window.applySiteRuleOverrides()).toBe(false);
|
||||
expect(window.tc.settings.subtitleNudgeEnabledByDefault).toBe(false);
|
||||
|
||||
window.resetSettingsFromSiteRuleBase();
|
||||
expect(window.tc.settings.subtitleNudgeEnabledByDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("detects media inside dynamically added shadow DOMs", async () => {
|
||||
await bootInject();
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
expect(window.vscAttachShadowPatched).toBe(true);
|
||||
|
||||
const host = document.createElement("custom-player");
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/dynamic.mp4";
|
||||
shadow.appendChild(video);
|
||||
document.body.appendChild(host);
|
||||
|
||||
// Flush MutationObserver microtasks so that the observer callback runs
|
||||
// and schedules requestIdleCallback's setTimeout.
|
||||
await flushAsyncWork();
|
||||
// Run the scheduled timers (requestIdleCallback)
|
||||
vi.runAllTimers();
|
||||
// Flush any remaining microtasks/promises
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div).toBeDefined();
|
||||
expect(video.vsc.div.classList.contains("vsc-non-youtube")).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("attaches a controller when Vidstack appends a source after the video", async () => {
|
||||
await bootInject();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const provider = document.createElement("media-provider");
|
||||
const video = document.createElement("video");
|
||||
provider.appendChild(video);
|
||||
document.body.appendChild(provider);
|
||||
|
||||
await flushAsyncWork();
|
||||
vi.runAllTimers();
|
||||
await flushAsyncWork();
|
||||
expect(video.vsc).toBeUndefined();
|
||||
|
||||
const source = document.createElement("source");
|
||||
source.src = "https://example.org/vidstack.mp4";
|
||||
video.appendChild(source);
|
||||
|
||||
await flushAsyncWork();
|
||||
vi.runAllTimers();
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div).toBeDefined();
|
||||
expect(video.vsc.div.classList.contains("vsc-nosource")).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("detects media in pre-existing shadow DOMs via delayed rescan", async () => {
|
||||
vi.useFakeTimers();
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
|
||||
const host = document.createElement("custom-player");
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/pre-existing.mp4";
|
||||
shadow.appendChild(video);
|
||||
document.body.appendChild(host);
|
||||
|
||||
globalThis.chrome = createChromeMock({ sync: {}, local: {} });
|
||||
window.chrome = globalThis.chrome;
|
||||
globalThis.chrome.runtime.onMessage = {
|
||||
addListener: vi.fn()
|
||||
};
|
||||
const originalSyncGet = globalThis.chrome.storage.sync.get;
|
||||
const originalLocalGet = globalThis.chrome.storage.local.get;
|
||||
globalThis.chrome.storage.sync.get = vi.fn((keys, callback) => {
|
||||
Promise.resolve().then(() => originalSyncGet(keys, callback));
|
||||
});
|
||||
globalThis.chrome.storage.local.get = vi.fn((keys, callback) => {
|
||||
Promise.resolve().then(() => originalLocalGet(keys, callback));
|
||||
});
|
||||
|
||||
loadScript("extension/shared/controller-utils.js");
|
||||
loadScript("extension/shared/key-bindings.js");
|
||||
loadScript("extension/shared/site-rules.js");
|
||||
loadScript("extension/shared/ui-icons.js");
|
||||
loadScript("extension/content/inject.js");
|
||||
|
||||
// Fast-forward 3000ms for delayed rescan to trigger
|
||||
vi.advanceTimersByTime(3000);
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await flushAsyncWork();
|
||||
}
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div).toBeDefined();
|
||||
expect(video.vsc.div.classList.contains("vsc-non-youtube")).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { afterEach, describe, expect, it } = require("vitest");
|
||||
const {
|
||||
evaluateScript,
|
||||
loadHtmlString
|
||||
@@ -10,8 +11,8 @@ describe("lucide-client.js", () => {
|
||||
|
||||
it("builds icon URLs and rejects invalid slugs", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/options/lucide-client.js");
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("lucide-client.js");
|
||||
|
||||
expect(window.lucideIconSvgUrl("alarm-clock")).toContain(
|
||||
"/icons/alarm-clock.svg"
|
||||
@@ -22,8 +23,8 @@ describe("lucide-client.js", () => {
|
||||
|
||||
it("sanitizes SVG before persisting a Lucide icon", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/options/lucide-client.js");
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("lucide-client.js");
|
||||
|
||||
const sanitized = window.sanitizeLucideSvg(`
|
||||
<svg width="10" height="10" onclick="evil()">
|
||||
@@ -42,8 +43,8 @@ describe("lucide-client.js", () => {
|
||||
|
||||
it("searches and ranks icon slugs by query", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/options/lucide-client.js");
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("lucide-client.js");
|
||||
|
||||
const results = window.searchLucideSlugs(
|
||||
{
|
||||
|
||||
@@ -7,16 +7,16 @@ import {
|
||||
} from "./helpers/browser.js";
|
||||
|
||||
async function setupOptions(overrides = {}) {
|
||||
loadHtml("extension/options/options.html");
|
||||
loadHtml("options.html");
|
||||
globalThis.chrome = createChromeMock(overrides);
|
||||
window.chrome = globalThis.chrome;
|
||||
globalThis.fetch = vi.fn();
|
||||
loadScript("extension/shared/controller-utils.js");
|
||||
loadScript("extension/shared/key-bindings.js");
|
||||
loadScript("extension/shared/popup-controls.js");
|
||||
loadScript("extension/shared/ui-icons.js");
|
||||
loadScript("extension/options/lucide-client.js");
|
||||
loadScript("extension/options/options.js");
|
||||
loadScript("shared/controller-utils.js");
|
||||
loadScript("shared/key-bindings.js");
|
||||
loadScript("shared/popup-controls.js");
|
||||
loadScript("ui-icons.js");
|
||||
loadScript("lucide-client.js");
|
||||
loadScript("options.js");
|
||||
triggerDomContentLoaded();
|
||||
await flushAsyncWork();
|
||||
return globalThis.chrome;
|
||||
@@ -29,7 +29,6 @@ describe("options page", () => {
|
||||
sync: {
|
||||
rememberSpeed: true,
|
||||
enabled: false,
|
||||
subtitleNudgeEnabledByDefault: false,
|
||||
popupMatchHoverControls: false,
|
||||
popupControllerButtons: ["rewind", "settings", "advance", "advance"],
|
||||
keyBindings: [
|
||||
@@ -40,7 +39,6 @@ describe("options page", () => {
|
||||
{
|
||||
pattern: "youtube.com",
|
||||
enabled: true,
|
||||
subtitleNudgeEnabledByDefault: false,
|
||||
showPopupControlBar: false,
|
||||
popupControllerButtons: ["advance", "settings", "advance"]
|
||||
}
|
||||
@@ -51,22 +49,12 @@ describe("options page", () => {
|
||||
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
|
||||
expect(document.getElementById("rememberSpeed").checked).toBe(true);
|
||||
expect(document.getElementById("enabled").checked).toBe(false);
|
||||
expect(document.getElementById("subtitleNudgeEnabledByDefault").checked).toBe(
|
||||
false
|
||||
);
|
||||
expect(document.querySelector('.shortcut-row[data-action="pause"]')).not.toBe(
|
||||
null
|
||||
);
|
||||
expect(document.getElementById("siteRulesContainer").children.length).toBe(
|
||||
1
|
||||
);
|
||||
expect(document.querySelector(".site-rule .override-subtitleNudge").checked).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
document.querySelector(".site-rule .site-subtitleNudgeEnabledByDefault")
|
||||
.checked
|
||||
).toBe(false);
|
||||
expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]);
|
||||
});
|
||||
|
||||
@@ -108,32 +96,6 @@ describe("options page", () => {
|
||||
expect(toggle.getAttribute("aria-label")).toBe("Collapse site rule");
|
||||
});
|
||||
|
||||
it("site rule shortcut override shows no rows by default and adds via selector", async () => {
|
||||
await setupOptions({ sync: { siteRules: [] } });
|
||||
|
||||
globalThis.createSiteRule({ pattern: "example.com" });
|
||||
const rule = document.getElementById("siteRulesContainer").lastElementChild;
|
||||
const rows = rule.querySelector(".site-shortcuts-rows");
|
||||
const selector = rule.querySelector(".site-add-shortcut-selector");
|
||||
|
||||
expect(rows.querySelectorAll(".shortcut-row").length).toBe(0);
|
||||
expect(selector).not.toBeNull();
|
||||
expect(selector.disabled).toBe(true);
|
||||
|
||||
rule.querySelector(".override-shortcuts").checked = true;
|
||||
rule.querySelector(".override-shortcuts").dispatchEvent(
|
||||
new Event("change", { bubbles: true })
|
||||
);
|
||||
|
||||
expect(selector.disabled).toBe(false);
|
||||
expect(selector.options.length).toBeGreaterThan(1);
|
||||
|
||||
selector.value = "pause";
|
||||
selector.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(rows.querySelectorAll('.shortcut-row[data-action="pause"]').length).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps site override settings visible but disabled until enabled", async () => {
|
||||
await setupOptions({ sync: { siteRules: [] } });
|
||||
|
||||
@@ -171,7 +133,6 @@ describe("options page", () => {
|
||||
document.getElementById("controllerMarginTop").value = "250";
|
||||
document.getElementById("controllerMarginBottom").value = "-4";
|
||||
document.getElementById("enableSubtitleNudge").checked = true;
|
||||
document.getElementById("subtitleNudgeEnabledByDefault").checked = false;
|
||||
document.getElementById("subtitleNudgeInterval").value = "5";
|
||||
document.getElementById("popupMatchHoverControls").checked = false;
|
||||
document.getElementById("showPopupControlBar").checked = false;
|
||||
@@ -190,10 +151,6 @@ describe("options page", () => {
|
||||
rule.querySelector(".site-rememberSpeed").checked = true;
|
||||
rule.querySelector(".override-opacity").checked = true;
|
||||
rule.querySelector(".site-controllerOpacity").value = "0";
|
||||
rule.querySelector(".override-subtitleNudge").checked = true;
|
||||
rule.querySelector(".site-enableSubtitleNudge").checked = true;
|
||||
rule.querySelector(".site-subtitleNudgeEnabledByDefault").checked = false;
|
||||
rule.querySelector(".site-subtitleNudgeInterval").value = "75";
|
||||
rule.querySelector(".override-popup-controlbar").checked = true;
|
||||
rule.querySelector(".site-showPopupControlBar").checked = false;
|
||||
globalThis.populateControlBarZones(
|
||||
@@ -218,7 +175,6 @@ describe("options page", () => {
|
||||
expect(savedSettings.controllerOpacity).toBe(0);
|
||||
expect(savedSettings.controllerMarginTop).toBe(200);
|
||||
expect(savedSettings.controllerMarginBottom).toBe(0);
|
||||
expect(savedSettings.subtitleNudgeEnabledByDefault).toBe(false);
|
||||
expect(savedSettings.subtitleNudgeInterval).toBe(10);
|
||||
expect(savedSettings.showPopupControlBar).toBe(false);
|
||||
expect(savedSettings.popupMatchHoverControls).toBe(false);
|
||||
@@ -229,9 +185,6 @@ describe("options page", () => {
|
||||
pattern: "youtube.com",
|
||||
rememberSpeed: true,
|
||||
controllerOpacity: 0,
|
||||
enableSubtitleNudge: true,
|
||||
subtitleNudgeEnabledByDefault: false,
|
||||
subtitleNudgeInterval: 75,
|
||||
showPopupControlBar: false,
|
||||
popupControllerButtons: ["advance"]
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
|
||||
const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
@@ -10,7 +11,7 @@ const {
|
||||
function bootOptions(options) {
|
||||
const config = options || {};
|
||||
|
||||
loadHtmlFile("extension/options/options.html");
|
||||
loadHtmlFile("options.html");
|
||||
installCommonWindowMocks();
|
||||
|
||||
const chrome = createChromeMock({
|
||||
@@ -30,12 +31,9 @@ function bootOptions(options) {
|
||||
);
|
||||
window.fetch = global.fetch;
|
||||
|
||||
evaluateScript("extension/shared/controller-utils.js");
|
||||
evaluateScript("extension/shared/key-bindings.js");
|
||||
evaluateScript("extension/shared/popup-controls.js");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/options/lucide-client.js");
|
||||
evaluateScript("extension/options/options.js");
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("lucide-client.js");
|
||||
evaluateScript("options.js");
|
||||
fireDOMContentLoaded();
|
||||
|
||||
return chrome;
|
||||
@@ -121,8 +119,7 @@ describe("options.js", () => {
|
||||
window.populatePopupControlBarEditor(["advance", "settings", "rewind"]);
|
||||
|
||||
window.createSiteRule({ pattern: "youtube.com" });
|
||||
const siteRuleEls = document.querySelectorAll(".site-rule");
|
||||
const ruleEl = siteRuleEls[siteRuleEls.length - 1];
|
||||
const ruleEl = document.querySelector(".site-rule");
|
||||
ruleEl.querySelector(".override-placement").checked = true;
|
||||
ruleEl.querySelector(".site-controllerLocation").value = "top-right";
|
||||
ruleEl.querySelector(".site-controllerMarginTop").value = "300";
|
||||
@@ -170,22 +167,19 @@ describe("options.js", () => {
|
||||
expect(savedSettings.controllerMarginTop).toBe(200);
|
||||
expect(savedSettings.controllerMarginBottom).toBe(0);
|
||||
expect(savedSettings.popupControllerButtons).toEqual(["advance", "rewind"]);
|
||||
expect(savedSettings.siteRules).toHaveLength(3);
|
||||
expect(savedSettings.siteRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pattern: "youtube.com",
|
||||
enabled: true,
|
||||
controllerLocation: "top-right",
|
||||
controllerMarginTop: 200,
|
||||
controllerMarginBottom: 0,
|
||||
hideWithControls: true,
|
||||
hideWithControlsTimer: 0.1,
|
||||
showPopupControlBar: false,
|
||||
popupControllerButtons: ["advance", "rewind"]
|
||||
})
|
||||
])
|
||||
);
|
||||
expect(savedSettings.siteRules).toEqual([
|
||||
{
|
||||
pattern: "youtube.com",
|
||||
enabled: true,
|
||||
controllerLocation: "top-right",
|
||||
controllerMarginTop: 200,
|
||||
controllerMarginBottom: 0,
|
||||
hideWithControls: true,
|
||||
hideWithControlsTimer: 0.1,
|
||||
showPopupControlBar: false,
|
||||
popupControllerButtons: ["advance", "rewind"]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks save when a site rule regex is invalid", async () => {
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
} from "./helpers/browser.js";
|
||||
|
||||
async function setupPopup(overrides = {}) {
|
||||
loadHtml("extension/popup/popup.html");
|
||||
loadHtml("popup.html");
|
||||
globalThis.chrome = createChromeMock(overrides);
|
||||
window.chrome = globalThis.chrome;
|
||||
loadScript("extension/shared/site-rules.js");
|
||||
loadScript("extension/shared/popup-controls.js");
|
||||
loadScript("extension/shared/ui-icons.js");
|
||||
loadScript("extension/popup/popup.js");
|
||||
loadScript("shared/site-rules.js");
|
||||
loadScript("shared/popup-controls.js");
|
||||
loadScript("ui-icons.js");
|
||||
loadScript("popup.js");
|
||||
triggerDomContentLoaded();
|
||||
await flushAsyncWork();
|
||||
return globalThis.chrome;
|
||||
@@ -29,29 +29,13 @@ describe("popup UI", () => {
|
||||
]
|
||||
});
|
||||
|
||||
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
|
||||
expect(document.getElementById("app-version").innerText).toBe("5.1.7.0");
|
||||
expect(document.getElementById("popupSpeed").textContent).toBe("1.75");
|
||||
expect(
|
||||
document.querySelectorAll("#popupControlBar button").length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows controls when globally disabled but a whitelist site rule matches", async () => {
|
||||
await setupPopup({
|
||||
sync: {
|
||||
enabled: false,
|
||||
siteRules: [{ pattern: "example.com", enabled: true }]
|
||||
}
|
||||
});
|
||||
|
||||
expect(document.getElementById("status").classList.contains("hide")).toBe(
|
||||
true
|
||||
);
|
||||
expect(document.getElementById("popupControlBar").style.display).not.toBe(
|
||||
"none"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows disabled state for a matching site rule", async () => {
|
||||
await setupPopup({
|
||||
sync: {
|
||||
@@ -60,7 +44,7 @@ describe("popup UI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
expect(document.getElementById("status").innerText).toBe(
|
||||
"Speeder is disabled for this site."
|
||||
);
|
||||
expect(document.getElementById("popupSpeed").textContent).toBe("1.00");
|
||||
@@ -85,9 +69,9 @@ describe("popup UI", () => {
|
||||
);
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
|
||||
path: {
|
||||
19: "assets/icons/icon19_disabled.png",
|
||||
38: "assets/icons/icon38_disabled.png",
|
||||
48: "assets/icons/icon48_disabled.png"
|
||||
19: "icons/icon19_disabled.png",
|
||||
38: "icons/icon38_disabled.png",
|
||||
48: "icons/icon48_disabled.png"
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -105,13 +89,13 @@ describe("popup UI", () => {
|
||||
});
|
||||
|
||||
document.getElementById("refresh").click();
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
expect(document.getElementById("status").innerText).toBe(
|
||||
"Cannot run on this page."
|
||||
);
|
||||
|
||||
response = { status: "complete" };
|
||||
document.getElementById("refresh").click();
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
expect(document.getElementById("status").innerText).toBe(
|
||||
"Scan complete. Closing..."
|
||||
);
|
||||
vi.advanceTimersByTime(500);
|
||||
@@ -134,28 +118,4 @@ describe("popup UI", () => {
|
||||
);
|
||||
expect(chrome.tabs.executeScript).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toggles force last saved speed and applies it to the active page", async () => {
|
||||
const chrome = await setupPopup({
|
||||
sync: { lastSpeed: 1.8, forceLastSavedSpeed: false }
|
||||
});
|
||||
chrome.tabs.sendMessage.mockClear();
|
||||
|
||||
document.getElementById("forceLastSavedSpeed").click();
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(chrome.storage.sync.__state.forceLastSavedSpeed).toBe(true);
|
||||
expect(
|
||||
document.getElementById("forceLastSavedSpeed").getAttribute("aria-pressed")
|
||||
).toBe("true");
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
action: "set_force_last_saved_speed",
|
||||
enabled: true,
|
||||
speed: 1.8
|
||||
},
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
|
||||
const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
@@ -10,7 +11,7 @@ const {
|
||||
function bootPopup(options) {
|
||||
const config = options || {};
|
||||
|
||||
loadHtmlFile("extension/popup/popup.html");
|
||||
loadHtmlFile("popup.html");
|
||||
installCommonWindowMocks();
|
||||
|
||||
const chrome = createChromeMock({
|
||||
@@ -46,10 +47,8 @@ function bootPopup(options) {
|
||||
global.chrome = chrome;
|
||||
window.chrome = chrome;
|
||||
|
||||
evaluateScript("extension/shared/site-rules.js");
|
||||
evaluateScript("extension/shared/popup-controls.js");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/popup/popup.js");
|
||||
evaluateScript("ui-icons.js");
|
||||
evaluateScript("popup.js");
|
||||
fireDOMContentLoaded();
|
||||
|
||||
return chrome;
|
||||
@@ -91,26 +90,25 @@ describe("popup.js", () => {
|
||||
});
|
||||
|
||||
it("builds sanitized popup buttons and refreshes speed after an action", async () => {
|
||||
let speedQueryCount = 0;
|
||||
const chrome = bootPopup({
|
||||
syncData: {
|
||||
enabled: true,
|
||||
controllerButtons: ["faster", "settings", "rewind", "faster"],
|
||||
popupMatchHoverControls: true
|
||||
},
|
||||
executeScriptImpl: (tabId, details, callback) => {
|
||||
speedQueryCount += 1;
|
||||
callback(
|
||||
speedQueryCount <= 2
|
||||
? [
|
||||
{ speed: 1.25, preferred: false },
|
||||
{ speed: 1.5, preferred: true }
|
||||
]
|
||||
: [{ speed: 1.75, preferred: true }]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.executeScript
|
||||
.mockImplementationOnce((tabId, details, callback) => {
|
||||
callback([
|
||||
{ speed: 1.25, preferred: false },
|
||||
{ speed: 1.5, preferred: true }
|
||||
]);
|
||||
})
|
||||
.mockImplementationOnce((tabId, details, callback) => {
|
||||
callback([{ speed: 1.75, preferred: true }]);
|
||||
});
|
||||
|
||||
chrome.tabs.sendMessage.mockImplementation((tabId, message, callback) => {
|
||||
if (message.action === "run_action") {
|
||||
callback({ speed: 1.75 });
|
||||
@@ -119,6 +117,7 @@ describe("popup.js", () => {
|
||||
callback({ speed: 1.0 });
|
||||
});
|
||||
|
||||
document.dispatchEvent(new window.Event("DOMContentLoaded"));
|
||||
await flushAsyncWork();
|
||||
|
||||
const buttons = Array.from(
|
||||
@@ -139,6 +138,8 @@ describe("popup.js", () => {
|
||||
});
|
||||
|
||||
it("toggles enablement and closes after a successful refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const chrome = bootPopup({
|
||||
syncData: {
|
||||
enabled: false
|
||||
@@ -146,7 +147,6 @@ describe("popup.js", () => {
|
||||
});
|
||||
|
||||
await flushAsyncWork();
|
||||
vi.useFakeTimers();
|
||||
|
||||
expect(document.querySelector("#enable").classList.contains("hide")).toBe(false);
|
||||
expect(document.querySelector("#disable").classList.contains("hide")).toBe(true);
|
||||
@@ -158,9 +158,9 @@ describe("popup.js", () => {
|
||||
);
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
|
||||
path: {
|
||||
19: "assets/icons/icon19.png",
|
||||
38: "assets/icons/icon38.png",
|
||||
48: "assets/icons/icon48.png"
|
||||
19: "icons/icon19.png",
|
||||
38: "icons/icon38.png",
|
||||
48: "icons/icon48.png"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import controllerUtils from "../extension/shared/controller-utils.js";
|
||||
import importExportUtils from "../extension/shared/import-export.js";
|
||||
import keyBindingUtils from "../extension/shared/key-bindings.js";
|
||||
import popupControls from "../extension/shared/popup-controls.js";
|
||||
import siteRules from "../extension/shared/site-rules.js";
|
||||
import controllerUtils from "../shared/controller-utils.js";
|
||||
import importExportUtils from "../shared/import-export.js";
|
||||
import keyBindingUtils from "../shared/key-bindings.js";
|
||||
import popupControls from "../shared/popup-controls.js";
|
||||
import siteRules from "../shared/site-rules.js";
|
||||
|
||||
describe("shared helpers", () => {
|
||||
it("matches site rules and skips invalid regex patterns", () => {
|
||||
@@ -24,20 +24,6 @@ describe("shared helpers", () => {
|
||||
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("combines global enabled with matched site rules (whitelist / blacklist)", () => {
|
||||
const allowSite = { pattern: "good.test", enabled: true };
|
||||
const blockSite = { pattern: "bad.test", enabled: false };
|
||||
|
||||
expect(siteRules.isSpeederActiveForSite(true, null)).toBe(true);
|
||||
expect(siteRules.isSpeederActiveForSite(false, null)).toBe(false);
|
||||
|
||||
expect(siteRules.isSpeederActiveForSite(true, blockSite)).toBe(false);
|
||||
expect(siteRules.isSpeederActiveForSite(false, blockSite)).toBe(false);
|
||||
|
||||
expect(siteRules.isSpeederActiveForSite(true, allowSite)).toBe(true);
|
||||
expect(siteRules.isSpeederActiveForSite(false, allowSite)).toBe(true);
|
||||
});
|
||||
|
||||
it("sanitizes and resolves popup button orders", () => {
|
||||
const controllerButtonDefs = {
|
||||
rewind: {},
|
||||
@@ -152,16 +138,6 @@ describe("shared helpers", () => {
|
||||
localSettings: null
|
||||
});
|
||||
|
||||
expect(
|
||||
importExportUtils.extractImportSettings({
|
||||
subtitleNudgeEnabledByDefault: false
|
||||
})
|
||||
).toEqual({
|
||||
isWrappedBackup: false,
|
||||
settings: { subtitleNudgeEnabledByDefault: false },
|
||||
localSettings: null
|
||||
});
|
||||
|
||||
expect(
|
||||
importExportUtils.extractImportSettings({ enabled: true })
|
||||
).toEqual({
|
||||
|
||||
@@ -76,7 +76,7 @@ function vscSanitizeSvgTree(svg) {
|
||||
n.remove();
|
||||
});
|
||||
|
||||
[svg].concat(Array.from(svg.querySelectorAll("*"))).forEach(function (el) {
|
||||
svg.querySelectorAll("*").forEach(function (el) {
|
||||
for (var i = el.attributes.length - 1; i >= 0; i--) {
|
||||
var attr = el.attributes[i];
|
||||
var name = attr.name.toLowerCase();
|
||||
@@ -94,7 +94,7 @@ function vscSanitizeSvgTree(svg) {
|
||||
}
|
||||
});
|
||||
|
||||
svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns", VSC_SVG_NS);
|
||||
svg.setAttribute("xmlns", VSC_SVG_NS);
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
return svg;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ module.exports = defineConfig({
|
||||
clearMocks: true,
|
||||
globals: true,
|
||||
restoreMocks: true,
|
||||
include: ["tests/**/*.test.js", "tests/**/*.spec.js"],
|
||||
include: ["tests/**/*.test.js"],
|
||||
setupFiles: ["./tests/setup.js"]
|
||||
}
|
||||
});
|
||||
|
||||