Compare commits

...

162 Commits

Author SHA1 Message Date
joshpatra e652850dfa v5.2.7.0-beta.1 2026-04-11 13:20:07 -04:00
joshpatra bfe20a84dd Bump version to 5.2.7.0 2026-04-11 13:20:06 -04:00
joshpatra 46a8a62110 refactor: reordered donate buttons, moved to icons over text 2026-04-11 13:19:57 -04:00
joshpatra de582ae157 v5.2.6.0-beta.1 2026-04-11 13:13:14 -04:00
joshpatra eb64de6ea3 Bump version to 5.2.6.0 2026-04-11 13:13:13 -04:00
joshpatra eab6d10a19 refactor: Better explanations, feat: made show/hide controller shortcut optional 2026-04-11 13:13:05 -04:00
joshpatra 76f6bd1a1e v5.2.5.0-beta.1 2026-04-11 13:01:55 -04:00
joshpatra 29a9a1d07f Bump version to 5.2.5.0 2026-04-11 13:01:54 -04:00
joshpatra 4d37b4f570 feat(options): site-rule shortcut picker, chevrons, defaults and site-rule help copy
fix(inject,popup): isSpeederActiveForSite whitelist/blacklist; tests; clear activeSiteRule on no match
2026-04-11 12:59:46 -04:00
joshpatra 9d41e6ceeb v5.2.3-beta.1 2026-04-10 13:18:18 -04:00
joshpatra 6d10a569d9 Bump version to 5.2.3 2026-04-10 13:18:17 -04:00
joshpatra 5f63718d62 fix(export): exclude lucide tags cache from backup payload 2026-04-10 13:17:49 -04:00
joshpatra eaffab1f27 v5.2.2.0-beta.1 2026-04-09 16:39:28 -04:00
joshpatra d01c01a216 Bump version to 5.2.2.0 2026-04-09 16:39:27 -04:00
joshpatra 0b6bc5d0a0 fix: some warnings, refactor: changed the donation look, moved it up 2026-04-09 16:39:13 -04:00
joshpatra 3f5983685f v5.2.0-beta.1 2026-04-07 15:38:54 -04:00
joshpatra 41d89e0993 Bump version to 5.2.0 2026-04-07 15:38:53 -04:00
joshpatra a424dea5ca feat: add optional volume shortcuts and control bar actions 2026-04-07 15:38:03 -04:00
joshpatra ab7ae99807 v5.1.9.0-beta.1 2026-04-07 14:54:34 -04:00
joshpatra 5d47c511be Bump version to 5.1.9.0 2026-04-07 14:54:33 -04:00
joshpatra 7713ba5bad Fix Firefox extension link in README
Updated Firefox extension link in README.md.
2026-04-07 14:53:49 -04:00
joshpatra ce0b28de4f Update shortcut validation and stop tracking VS Code settings.
Allow empty keybinds for optional shortcuts while requiring Show/Hide controller, Decrease speed, and Increase speed, and remove tracked .vscode files while keeping the folder gitignored.
2026-04-07 14:52:50 -04:00
joshpatra a2b041e225 v5.1.8.0-beta.1 2026-04-07 14:33:07 -04:00
joshpatra 4f87aadfd7 Bump version to 5.1.8.0 2026-04-07 14:32:21 -04:00
joshpatra 8456111bf0 fix: correct AMO URL and lucide spec object key 2026-04-07 14:31:45 -04:00
joshpatra 6efe92a036 Refine site rule toggle and override UI 2026-04-07 14:31:41 -04:00
joshpatra 0cb13905ff Fix subtitle nudge site gating 2026-04-07 14:31:39 -04:00
joshpatra f32d1b3f71 Accept raw settings backups during import 2026-04-07 14:31:36 -04:00
joshpatra e6c56bcecb Allow zero controller opacity in settings 2026-04-07 14:31:34 -04:00
joshpatra a7a0aafd68 Add Vitest suite and fix wrapped local import restore 2026-04-07 14:31:27 -04:00
joshpatra 3cf1a4acd1 style(inject): normalize formatting 2026-04-04 16:14:56 -04:00
joshpatra a9956831c4 refactor(shortcuts): switch shortcut bindings to event.code 2026-04-04 13:33:11 -04:00
joshpatra 25d3acf576 v5.1.7.0-beta.1 2026-04-02 22:34:00 -04:00
joshpatra 7b8b4324af Bump version to 5.1.7.0 2026-04-02 22:33:59 -04:00
joshpatra 8b9e4bea1d fix: refresh site rules on DOM video changes 2026-04-02 21:16:37 -04:00
joshpatra 8c94cc2088 fix: clarify site rule auto-hide copy 2026-04-02 21:08:15 -04:00
joshpatra 19d3af02a2 refactor: store settings as sparse diffs 2026-04-02 21:07:31 -04:00
joshpatra 306e0e3ea0 Exclude Lucide cache from backups 2026-04-02 20:47:32 -04:00
joshpatra 1536c13c3e v5.1.6-beta.1 2026-04-02 18:20:49 -04:00
joshpatra 6bd319c8cc Bump version to 5.1.6 2026-04-02 18:20:48 -04:00
joshpatra 3aee8c8f9a fix: errors from web-ext 2026-04-02 18:20:33 -04:00
joshpatra 939ee08466 v5.1.4-beta.1 2026-04-02 14:17:18 -04:00
joshpatra 5a175c3cf8 Bump version to 5.1.4 2026-04-02 14:17:17 -04:00
joshpatra 805e5a82e5 fix: unicode reset glyph fallback in extension popup 2026-04-02 14:16:53 -04:00
joshpatra df34b1fee9 feat: Lucide subtitle nudge on/off targets and dual preview in options 2026-04-02 14:16:46 -04:00
joshpatra 0741c6e535 feat: custom Lucide icons for subtitle nudge on/off in inject 2026-04-02 14:16:40 -04:00
joshpatra fad0c49e65 v5.1.3-beta.1 2026-04-02 13:56:22 -04:00
joshpatra 66075fb6f3 Bump version to 5.1.3 2026-04-02 13:56:21 -04:00
joshpatra bf4025dcb4 fix: settings update 2026-04-02 13:54:01 -04:00
joshpatra 76a7b933bb v5.1.2-beta.1 2026-04-02 13:52:04 -04:00
joshpatra 1cd533fc5c Bump version to 5.1.2 2026-04-02 13:52:02 -04:00
joshpatra 8c5bd68d39 fix: popup control bar section layout in options 2026-04-02 13:44:03 -04:00
joshpatra 9c257af446 feat: omit settings from popup control bar 2026-04-02 13:43:56 -04:00
joshpatra 64a9b85587 fix: control bar icon clicks, hover/focus-within, nudge action 2026-04-02 13:43:43 -04:00
joshpatra edd997037a v5.1.1-beta.1 2026-04-02 13:11:47 -04:00
joshpatra f85a1f9f29 Bump version to 5.1.1 2026-04-02 13:11:46 -04:00
joshpatra 97366b76b6 chore: open options in tab 2026-04-02 13:09:09 -04:00
joshpatra 8269875bb1 fix: removed divider 2026-04-02 13:01:14 -04:00
joshpatra e34ec17f33 v5.1.0-beta.1 2026-04-02 12:53:10 -04:00
joshpatra 8d3905b654 Bump version to 5.1.0 2026-04-02 12:53:09 -04:00
joshpatra 7fd8a931d8 deploy: squash beta→main for stable; beta script pushes dev then pulls 2026-04-02 12:52:27 -04:00
joshpatra 17319c1e25 Re-run site rules on DOM media attach; extract refreshAllControllerGeometry 2026-04-02 12:52:27 -04:00
joshpatra 841c1a246e fix: nudge flash layout, Lucide icons, hover bar spacing 2026-04-02 12:52:27 -04:00
joshpatra ed0f63e8bc feat: user-customizable Lucide controller button icons 2026-04-02 12:52:27 -04:00
joshpatra 53f66f1eeb v5.0.4-beta.1 2026-04-01 16:31:49 -04:00
joshpatra f106ab490a Bump version to 5.0.4 2026-04-01 16:31:48 -04:00
joshpatra 5a38121e09 refactor: scripts update 2026-04-01 16:31:29 -04:00
joshpatra 36ed922b5c Add interactive deploy scripts for beta and AMO stable releases 2026-04-01 16:29:19 -04:00
joshpatra 3275d1f322 v5.0.2-beta.1 2026-04-01 16:24:24 -04:00
joshpatra f6d706f096 chore: version bump, deployment update 2026-04-01 16:21:44 -04:00
joshpatra 04292a8018 refactor: update settings, feat: change reset speed indicator to show speed it changes to/from 2026-04-01 16:18:36 -04:00
joshpatra 8eb3901121 v5.0.2-beta.1 2026-04-01 15:32:37 -04:00
joshpatra 0bcca24241 fix: switch from url based video diffing to dom based 2026-04-01 15:20:22 -04:00
joshpatra 6bf48fa479 v5.0.1-beta.1 2026-04-01 11:28:47 -04:00
joshpatra 9b4f338ebb fix: remember playback speed site switching behavior 2026-04-01 11:28:11 -04:00
joshpatra 3a583ce3b8 v5.0.1-beta.1 2026-04-01 11:19:06 -04:00
joshpatra 06f40b3d6d feat: top/bottom margin setting, fix: site-specific rule overrides, refactor: wording for settings 2026-04-01 11:18:30 -04:00
joshpatra 5c20d7ae0b v5.0.0-beta.1 2026-03-31 15:03:56 -04:00
joshpatra 068e65f62f chore: update manifest.json 2026-03-31 15:03:23 -04:00
joshpatra b0498cd1d7 Merge dev 2026-03-31 15:01:17 -04:00
joshpatra 52080a4185 Add .vscode to .gitignore 2026-03-31 15:01:16 -04:00
joshpatra 85215ae54b Merge main: update beta install instructions 2026-03-31 14:53:41 -04:00
joshpatra 9f8c235cea Merge dev: update beta install instructions 2026-03-31 14:53:41 -04:00
joshpatra 30c5a7dbe3 Update beta release install instructions for Firefox XPI auto-install 2026-03-31 14:53:30 -04:00
joshpatra 597c7a8af5 Merge main: fix beta signing via AMO unlisted 2026-03-31 14:48:47 -04:00
joshpatra d215ad4224 Merge dev: fix beta signing via AMO unlisted 2026-03-31 14:48:46 -04:00
joshpatra 705667b532 Fix beta signing: use AMO unlisted channel instead of unsigned build
Unsigned XPIs are blocked by Firefox. Beta releases must be signed via
AMO --channel unlisted to allow manual installation.
2026-03-31 14:48:37 -04:00
joshpatra df18e638c2 Merge main: fix XPI rename glob 2026-03-31 14:44:46 -04:00
joshpatra 9ff3ce4f74 Merge dev: fix XPI rename glob 2026-03-31 14:44:39 -04:00
joshpatra 11e3518902 Fix XPI rename: web-ext build outputs .zip not .xpi 2026-03-31 14:44:24 -04:00
joshpatra 91dc47f5a8 Merge main: restructure CI/CD workflow 2026-03-31 14:39:11 -04:00
joshpatra 36a60bbe34 Merge dev: restructure CI/CD workflow 2026-03-31 14:39:04 -04:00
joshpatra ea48170e11 Restructure CI/CD: tag-based releases, branch-based routing
- v* tags without -beta → sign & submit to AMO (listed/public)
- v*-beta tags → GitHub prerelease XPI for self-hosting
- dev branch pushes → lint-only CI, no release artifacts
2026-03-31 14:38:46 -04:00
joshpatra 3bcb36f6d0 Merge branch 'dev' into beta 2026-03-31 14:22:24 -04:00
joshpatra f2cf8b6205 Fix workflow: use system jq, tag-based builds 2026-03-31 14:22:14 -04:00
joshpatra 2747c15924 Test beta prerelease 2026-03-31 14:18:49 -04:00
joshpatra 5e7d5d6d21 Merge branch 'dev' into beta 2026-03-31 14:18:32 -04:00
joshpatra 6045ac5688 Fix GitHub Actions jq error 2026-03-31 14:18:30 -04:00
joshpatra 38174a2a76 Test beta prerelease 2026-03-31 14:15:17 -04:00
joshpatra 8569fa6e33 ci: add Firefox AMO + GitHub release workflow 2026-03-31 14:11:05 -04:00
joshpatra 30ba6585b0 fix: import/export 2026-03-31 14:06:17 -04:00
joshpatra 8d48dcccca fix: dragging from middle positions 2026-03-31 01:08:57 -04:00
joshpatra 75acaada8d feat: uniform spacing between controls 2026-03-31 01:02:43 -04:00
joshpatra 6d993edf50 feat(ui): configurable control bar, popup controls, and settings overhaul 2026-03-31 00:48:47 -04:00
joshpatra b4030f91dd feat: settings update 2026-03-30 16:25:03 -04:00
joshpatra 44c9c576ed Add donation footer to options page 2026-03-30 15:46:54 -04:00
joshpatra 72f590cd02 fix: mouseover behavior 2026-03-29 18:18:02 -04:00
joshpatra caf75b3e54 fix: closes #17 for real this time 2026-03-29 16:22:43 -04:00
joshpatra 37d1831b8b feat: a minimal fix to try and close #17 2026-03-29 15:28:10 -04:00
joshpatra 14e37c62d5 feat: hide with controls timer for non-youtube websites, site-specific rules for it too 2026-03-29 15:13:59 -04:00
joshpatra 03f5dbaa96 fix: import/export, and saving settings fixed 2026-03-29 14:43:25 -04:00
joshpatra 8b30005fb2 chore: swap versioning for Github and Firefox 2026-03-29 12:03:01 -04:00
joshpatra f385074905 chore: replace the default disabled websites with example domains 2026-03-28 19:15:37 -04:00
joshpatra a51801e7c3 fix: made hide with controls site-specific accidentally 2026-03-28 19:08:14 -04:00
joshpatra cd5e4bee9f feat: add setting to hide controller with YouTube controls, restoring a bug from before lol 2026-03-28 18:57:48 -04:00
joshpatra 4d34c8754d fix: expand from right for middle positions 2026-03-28 17:31:18 -04:00
joshpatra 194acacab4 fix: z-index regression, fullscreen top padding 2026-03-28 17:25:34 -04:00
joshpatra 6cf4ef3f93 feat: add import/export settings with modular architecture 2026-03-28 16:30:37 -04:00
joshpatra 104b828d47 feat: improve removal button visibility and standardize site rules 2026-03-28 16:03:06 -04:00
joshpatra 318598b8cf fix: restore default keybinding population in options 2026-03-28 16:02:50 -04:00
joshpatra 5616778421 feat: Site-Specific Rules stash pop, cleaned up some settings 2026-03-28 11:38:13 -04:00
joshpatra bec32b4c0e Video controller: adjust bottom padding for controller locations 2026-03-28 11:31:06 -04:00
joshpatra 2832598881 feat: add the 'Preferences' pane to the Addon Menu 2026-03-28 10:00:53 -04:00
joshpatra 079834ca72 fix: bring back blocklisted websites, must've accidentally removed them 2026-03-28 03:31:48 -04:00
joshpatra d44010f7a3 feat: Default controller location, easy moving of Controller with new shortcut 2026-03-28 03:21:50 -04:00
joshpatra b02cdb14b3 feat: Hijack Youtube's native playback speed controller for some performance gain, at least up to 2.0, and afterwards, use the nudging mechanism 2026-03-28 02:54:33 -04:00
joshpatra be8342fa18 fix: nudge now works on YouTube embeds on sites like KhanAcademy, refactor: use querySelectorAll instead of recursively calling scanNodeForMedia on every element in the DOM 2026-03-28 01:57:14 -04:00
joshpatra f96fd4ac14 feat: flash indicator, alongisde the hover control indicator, autohides one when the other appears 2026-03-28 01:33:14 -04:00
joshpatra b96d1a62ec Fix YouTube hover controls & improve nudge indicator UX 2026-03-28 01:21:35 -04:00
joshpatra ab85de0a0f fix: separate Speeder from yt autohide, ignore temp folder with magnus fork to reduce size, update build.py 2026-03-26 18:43:34 -04:00
joshpatra 64f30a04c0 chore: version bump 2026-03-26 13:53:43 -04:00
joshpatra 303864e37b feat(nudge): add per-tab toggle, status badge, and cpu tuning 2026-03-26 13:53:13 -04:00
joshpatra b1c7cb6741 fix(controller): reveal hover controls from the visible pill 2026-03-26 13:11:01 -04:00
joshpatra f8e846ab0c chore: remove __pycache__ from index and gitignore it 2026-03-23 19:44:03 -04:00
joshpatra c6bb82894e Merge pull request #4 from Gitoffthelawn/patch-11
Improve usability, improve accuracy, fix links
2026-03-23 19:39:49 -04:00
Gitoffthelawn 1fc6ab278b Improve usability, improve accuracy, fix links 2026-03-23 12:41:13 -07:00
joshpatra f2c128094e Merge branch 'firefox-port' of github.com:SoPat712/videospeed into firefox-port 2026-03-23 13:23:04 -04:00
joshpatra f34ad12b88 feat: Changed name to Speeder 2026-03-23 13:23:00 -04:00
joshpatra c309fbb743 Update funding sources in FUNDING.yml 2026-03-23 12:57:32 -04:00
joshpatra 6765a6e131 fix(z-index): accidentally left the z-index at 99999, meaning it was showing above all elements on the page 2026-03-23 02:24:44 -04:00
joshpatra a37054efe1 fix: whoops, committed the original 2026-03-21 11:26:42 -04:00
joshpatra f5692e659c fix(detection+reliability): better at recognizing oddly sourced videos, better at not losing track of its speed, better at hiding properly, redid keyboard shortcut mechanism for more stability, added a version number to the settings and menu, hopefully fixed bugs surrounding skipping/pausing resetting the speed 2026-03-21 11:25:56 -04:00
joshpatra ef86a70ca5 general performance updates 2025-11-25 10:44:52 -05:00
joshpatra 5009e83f62 updates for gpu usage 2025-11-19 14:11:55 -05:00
joshpatra 1f8cb4411e Merge branch 'firefox-port' of github.com:SoPat712/videospeed into firefox-port 2025-09-25 18:12:04 -04:00
joshpatra 05a8adef80 gitignore update 2025-09-25 18:12:01 -04:00
joshpatra 4e772cc9e1 Update README.md 2025-07-22 13:36:18 -04:00
joshpatra a10a429aa9 Create LICENSE 2025-07-22 13:35:48 -04:00
joshpatra 01b9b576eb dark mode 2025-07-22 13:19:14 -04:00
joshpatra 893c811802 CPU usage fix since nudging didn't stop properly 2025-07-22 12:17:54 -04:00
joshpatra 3fed3b425e add feature in popup to force search for videos, on websites that it doesn't show up on 2025-07-18 18:00:06 -04:00
joshpatra d89853b4d2 Better speed controlling logic, more selective speed reapplication 2025-07-06 22:56:48 -04:00
joshpatra d94ab958d5 failing to appear, making it more consistent 2025-07-06 19:40:25 -04:00
joshpatra 1277750716 versioning stuff 2025-07-03 15:28:21 -04:00
joshpatra 247a46d430 I'm stupid, the problem was resuming, not pausing 2025-07-03 15:14:19 -04:00
joshpatra 703658335c version bump but still broken 2025-07-03 15:02:38 -04:00
joshpatra b2ed0fcb41 add deploy script 2025-07-03 14:49:52 -04:00
joshpatra 3dfee251ec fix pause reset 2025-07-03 14:42:25 -04:00
joshpatra 8e0183d8af general fixes, mistake versioning 2025-07-03 13:49:56 -04:00
joshpatra 73827b5ee0 Merge branch 'firefox-port' of github.com:SoPat712/videospeed into firefox-port 2025-07-02 14:36:43 -04:00
joshpatra b07e7cb394 youtube embeds fixed and shortcuts 2025-07-02 14:36:39 -04:00
joshpatra c3166cf347 Update README.md 2025-06-22 01:30:24 -04:00
47 changed files with 14919 additions and 1477 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [SoPat712]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: joshpatra
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: treeman183
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+75
View File
@@ -0,0 +1,75 @@
name: Build & Release Firefox Addon
on:
push:
tags:
- "v*"
branches:
- dev
jobs:
build:
runs-on: ubuntu-latest
env:
WEB_EXT_IGNORE_FILES: scripts/**
steps:
- uses: actions/checkout@v4
- name: Install deps
run: npm install -g web-ext
- name: Lint
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
- name: Sign XPI via AMO (unlisted, beta)
if: contains(github.ref_name, '-beta')
run: |
web-ext sign \
--api-key ${{ secrets.FIREFOX_API_KEY }} \
--api-secret ${{ secrets.FIREFOX_API_SECRET }} \
--source-dir . \
--artifacts-dir web-ext-artifacts \
--channel unlisted
- name: Get signed XPI path (beta)
if: contains(github.ref_name, '-beta')
id: xpi
run: |
FILE=$(ls web-ext-artifacts/*.xpi | head -n 1)
XPI="web-ext-artifacts/speeder-${{ github.ref_name }}.xpi"
mv "$FILE" "$XPI"
echo "file=$XPI" >> $GITHUB_OUTPUT
- name: Create GitHub Prerelease (beta)
if: contains(github.ref_name, '-beta')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
files: ${{ steps.xpi.outputs.file }}
prerelease: true
body: |
Beta build — signed for self-hosted manual installation.
### Installation Instructions
1. Click the `.xpi` file below
2. Click "Continue to Installation"
3. Click "Add"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Stable tag (v* without -beta) → Sign & submit to public AMO listing
- name: Sign & Submit to AMO (stable)
if:
startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name,
'-beta')
run: |
web-ext sign \
--api-key ${{ secrets.FIREFOX_API_KEY }} \
--api-secret ${{ secrets.FIREFOX_API_SECRET }} \
--source-dir . \
--artifacts-dir web-ext-artifacts \
--channel listed
+4
View File
@@ -1,6 +1,10 @@
.DS_Store
.vscode/
local
# IntelliJ IDEA
*.xpi
.idea/
node_modules
__pycache__/
temp/
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+15 -17
View File
@@ -1,5 +1,5 @@
[![Add to Firefox](https://img.shields.io/badge/Add%20to-Firefox-orange?logo=firefox&logoColor=white)](https://addons.mozilla.org/en-US/firefox/addon/video-speed-controller-v1/)
# Available for Firefox
[![Add to Firefox](https://img.shields.io/badge/Add%20to-Firefox-orange?logo=firefox&logoColor=white)](https://addons.mozilla.org/firefox/addon/speeder/)
# The science of accelerated playback
@@ -7,19 +7,19 @@
**TL;DR: faster playback translates to better engagement and retention.**
Average adult reads prose text at
[250 to 300 words per minute](http://www.paperbecause.com/PIOP/files/f7/f7bb6bc5-2c4a-466f-9ae7-b483a2c0dca4.pdf)
[250 to 300 words per minute](https://web.archive.org/web/20220325065025/https://www.domtar.com/en/resources/paper-advocacy/paperbecause)
(wpm). By contrast, the average rate of speech for English speakers is ~150 wpm,
with slide presentations often closer to 100 wpm. As a result, when given the
choice, many viewers
[speed up video playback to ~1.3\~1.5 its recorded rate](http://research.microsoft.com/en-us/um/redmond/groups/coet/compression/chi99/paper.pdf)
[speed up video playback to ~1.3\~1.5 its recorded rate](https://research.microsoft.com/en-us/um/redmond/groups/coet/compression/chi99/paper.pdf)
to compensate for the difference.
Many viewers report that
[accelerated viewing keeps their attention longer](http://www.enounce.com/docs/BYUPaper020319.pdf):
[accelerated viewing keeps their attention longer](https://www.enounce.com/docs/BYUPaper020319.pdf):
faster delivery keeps the viewer more engaged with the content. In fact, with a
little training many end up watching videos at 2x+ the recorded speed. Some
studies report that after being exposed to accelerated playback,
[listeners become uncomfortable](http://alumni.media.mit.edu/~barons/html/avios92.html#beasleyalteredspeech)
[listeners become uncomfortable](https://web.archive.org/web/20230912173120/http://alumni.media.mit.edu/~barons/html/avios92.html)
if they are forced to return to normal rate of presentation.
## Faster HTML5 Video
@@ -33,10 +33,10 @@ last point to listen to it a few more times.
![Player](https://cloud.githubusercontent.com/assets/2400185/24076745/5723e6ae-0c41-11e7-820c-1d8e814a2888.png)
#### *Install [Chrome](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk) or [Firefox](https://addons.mozilla.org/en-us/firefox/addon/videospeed/) 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*
\*\* Once the extension is installed simply navigate to any page that offers
HTML5 video ([example](http://www.youtube.com/watch?v=E9FxNzv1Tr8)), and you'll
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,
even better, simply use your keyboard:
@@ -56,7 +56,7 @@ shortcuts with different values, which will allow you to quickly toggle between
your most commonly used speeds. To add a new shortcut, open extension settings
and click "Add New".
![settings Add New shortcut](https://user-images.githubusercontent.com/121805/50726471-50242200-1172-11e9-902f-0e5958387617.jpg)
<img width="1760" height="1330" alt="image" src="https://github.com/user-attachments/assets/32e814dd-93ea-4943-8ec9-3eca735447ac" />
Some sites may assign other functionality to one of the assigned shortcut keys —
these collisions are inevitable, unfortunately. As a workaround, the extension
@@ -74,14 +74,12 @@ 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 of `igrigorik/videospeed` all about?** This fork of the
[`igrigorik/videospeed`](https://github.com/igrigorik/videospeed) repository
is a port of [`igrigorik`](https://github.com/igrigorik)'s videospeed Chrome
add-on for Firefox. This fork modifies the Chrome add-on code so that it works
in Firefox. This repo is the code behind the [Firefox Extension](https://addons.mozilla.org/en-US/firefox/addon/video-speed-controller-v1/)
whereas the [`igrigorik/videospeed`](https://github.com/igrigorik/videospeed)
repository contains the code behind the [Chrome Extension](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk).
**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
(MIT License) - Copyright (c) 2014 Josh Patra
(GPLv3) - Copyright (c) 2025 Josh Patra
+5
View File
@@ -0,0 +1,5 @@
chrome.runtime.onMessage.addListener(function (request) {
if (request.action === "openOptions") {
chrome.tabs.create({ url: chrome.runtime.getURL("options.html") });
}
});
+157
View File
@@ -0,0 +1,157 @@
import glob
import fnmatch
import os
import re
import shutil
import tempfile
import zipfile
SCRIPT_NAME = os.path.basename(__file__)
TARGET_FILE = "manifest.json"
DEFAULT_EXCLUDE_FILES = {".DS_Store"}
DEFAULT_EXCLUDE_DIRS = {"__pycache__", "temp"}
DEFAULT_EXCLUDE_PATTERNS = {"._*", "*.pyc"}
def should_exclude(rel_path, exclude_files, exclude_dirs):
rel_path = os.path.normpath(rel_path)
path_parts = rel_path.split(os.sep)
file_name = path_parts[-1]
if file_name in DEFAULT_EXCLUDE_FILES or rel_path in DEFAULT_EXCLUDE_FILES:
return True
if any(part in DEFAULT_EXCLUDE_DIRS for part in path_parts):
return True
if file_name in exclude_files or rel_path in exclude_files:
return True
if any(part in exclude_dirs for part in path_parts):
return True
if any(fnmatch.fnmatch(file_name, pattern) for pattern in DEFAULT_EXCLUDE_PATTERNS):
return True
return False
def zip_folder(output_name, folder, exclude_files, exclude_dirs):
with zipfile.ZipFile(output_name, "w", zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(folder):
dirs[:] = [
d
for d in dirs
if not should_exclude(
os.path.relpath(os.path.join(root, d), folder),
exclude_files,
exclude_dirs,
)
]
for file in files:
rel_path = os.path.relpath(os.path.join(root, file), folder)
if should_exclude(rel_path, exclude_files, exclude_dirs):
continue
zipf.write(os.path.join(root, file), arcname=rel_path)
def update_version_line(file_path, new_version):
with open(file_path, "r", encoding="utf-8") as f:
lines = f.readlines()
updated = False
for i, line in enumerate(lines):
match = re.match(r'\s*"version":\s*"([^"]+)"', line)
if match:
old_version = match.group(1)
lines[i] = re.sub(
r'"version":\s*".+?"', f'"version": "{new_version}"', line
)
updated = True
print(
f"🛠️ Changed version in {file_path} from {old_version}{new_version}"
)
break
if updated:
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(lines)
else:
print(f"⚠️ No version line found in {file_path}.")
def main():
# Step 0: Remove all existing .xpi files upfront
xpi_files = glob.glob("*.xpi")
for f in xpi_files:
try:
os.remove(f)
print(f"🗑️ Removed existing archive: {f}")
except Exception as e:
print(f"⚠️ Failed to remove {f}: {e}")
# Read current version from manifest.json
current_dir = os.getcwd()
manifest_path = os.path.join(current_dir, TARGET_FILE)
current_version = "unknown"
if os.path.exists(manifest_path):
with open(manifest_path, "r", encoding="utf-8") as f:
for line in f:
match = re.match(r'\s*"version":\s*"([^"]+)"', line)
if match:
current_version = match.group(1)
break
print(f"📦 Current version: {current_version}")
base_version = input("Enter the new base version (e.g., 2.0.1): ").strip()
if not base_version:
print("❌ No version entered. Exiting.")
return
github_version = f"{base_version}.0"
# Step 1: Update manifest.json on disk to base_version (for Firefox)
if os.path.exists(manifest_path):
update_version_line(manifest_path, base_version)
else:
print(f"{TARGET_FILE} not found. Aborting.")
return
# Step 2: Create videospeed-firefox.xpi (exclude script, .git, AND videospeed-firefox.xpi itself)
exclude_files = [SCRIPT_NAME, "videospeed-firefox.xpi"]
exclude_dirs = [".git"]
zip_folder("videospeed-firefox.xpi", current_dir, exclude_files, exclude_dirs)
print("✅ Created videospeed-firefox.xpi")
# Step 3: Re-scan for .xpi files after Firefox archive creation, exclude them for GitHub zip
current_xpi_files = set(glob.glob("*.xpi"))
exclude_temp_files = current_xpi_files.union({SCRIPT_NAME})
exclude_temp_dirs = set(exclude_dirs)
# Step 4: Create videospeed-github.xpi from temp folder with version bumped to .0
with tempfile.TemporaryDirectory() as temp_dir:
for item in os.listdir(current_dir):
if should_exclude(item, exclude_temp_files, exclude_temp_dirs):
continue
src = os.path.join(current_dir, item)
dst = os.path.join(temp_dir, item)
if os.path.isdir(src):
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
temp_manifest = os.path.join(temp_dir, TARGET_FILE)
if os.path.exists(temp_manifest):
update_version_line(temp_manifest, github_version)
else:
print(f"⚠️ {TARGET_FILE} not found in temp folder.")
zip_folder(
"videospeed-github.xpi", temp_dir, exclude_files=[], exclude_dirs=[]
)
print("✅ Created videospeed-github.xpi")
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
/* Runs via chrome.tabs.executeScript(allFrames) in the same isolated world as inject.js */
(function () {
try {
if (typeof getPrimaryVideoElement !== "function") {
return null;
}
var v = getPrimaryVideoElement();
if (!v) return null;
return {
speed: v.playbackRate,
preferred: !v.paused
};
} catch (e) {
return null;
}
})();
+13
View File
@@ -0,0 +1,13 @@
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="kofiSymbolMask" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
</mask>
<g mask="url(#kofiSymbolMask)">
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+162
View File
@@ -0,0 +1,162 @@
// Import/Export functionality for Video Speed Controller settings
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var importExportUtils = speederShared.importExport || {};
function generateBackupFilename() {
return importExportUtils.generateBackupFilename(new Date());
}
function exportSettings() {
chrome.storage.sync.get(null, function (storage) {
chrome.storage.local.get(null, function (localStorage) {
const backup = importExportUtils.buildBackupPayload(
storage,
importExportUtils.filterLocalSettingsForExport(localStorage),
new Date()
);
const dataStr = JSON.stringify(backup, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = generateBackupFilename();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showStatus("Settings exported successfully");
});
});
}
function importSettings() {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = function (event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
try {
const parsedBackup = importExportUtils.parseImportText(e.target.result);
if (!parsedBackup) {
showStatus("Error: Invalid backup file format", true);
return;
}
var settingsToImport = parsedBackup.settings;
var localToImport = parsedBackup.localSettings;
function importLocalSettings(callback) {
if (parsedBackup.isWrappedBackup !== true) {
callback();
return;
}
chrome.storage.local.clear(function () {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to clear local extension data - " +
chrome.runtime.lastError.message,
true
);
return;
}
if (localToImport && Object.keys(localToImport).length > 0) {
chrome.storage.local.set(localToImport, function () {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to save local extension data - " +
chrome.runtime.lastError.message,
true
);
return;
}
callback();
});
return;
}
callback();
});
}
function afterLocalImport() {
chrome.storage.sync.clear(function () {
chrome.storage.sync.set(settingsToImport, function () {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to save imported settings - " +
chrome.runtime.lastError.message,
true
);
return;
}
showStatus("Settings imported successfully. Reloading...");
setTimeout(function () {
if (typeof restore_options === "function") {
restore_options();
} else {
location.reload();
}
}, 500);
});
});
}
importLocalSettings(afterLocalImport);
} catch (err) {
showStatus("Error: Failed to parse backup file - " + err.message, true);
}
};
reader.onerror = function () {
showStatus("Error: Failed to read file", true);
};
reader.readAsText(file);
};
input.click();
}
function showStatus(message, isError = false) {
const status = document.getElementById("status");
if (status) {
status.textContent = message;
status.style.color = isError ? "#d32f2f" : "";
setTimeout(function () {
status.textContent = "";
status.style.color = "";
}, 3000);
}
}
// Initialize import/export buttons when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initImportExport);
} else {
initImportExport();
}
function initImportExport() {
const exportBtn = document.getElementById("exportSettings");
const importBtn = document.getElementById("importSettings");
if (exportBtn) {
exportBtn.addEventListener("click", exportSettings);
}
if (importBtn) {
importBtn.addEventListener("click", importSettings);
}
}
+31 -66
View File
@@ -1,83 +1,48 @@
/* Base styles for the controller wrapper (the shadow host) */
.vsc-controller {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
pointer-events: none !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;
}
.vsc-hidden {
display: none !important;
}
.vsc-manual {
visibility: visible !important;
opacity: 1 !important;
}
.vsc-controller {
/* In case of pages using `white-space: pre-line` (eg Discord), don't render vsc's whitespace */
white-space: normal;
}
/* Origin specific overrides */
/* YouTube player */
.ytp-hide-info-bar .vsc-controller {
position: relative;
top: 10px;
}
.ytp-autohide .vsc-controller {
/* YouTube auto-hide feature: fade controller with YouTube's controls */
/* When the wrapper has ytp-autohide class, hide it (unless vsc-hidden overrides) */
.vsc-controller.ytp-autohide:not(.vsc-hidden),
.vsc-controller.vsc-idle-hidden:not(.vsc-hidden) {
visibility: hidden;
transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
}
.ytp-autohide .vcs-show {
visibility: visible;
opacity: 1;
/* Show it temporarily when it has vsc-show class */
.vsc-controller.ytp-autohide.vsc-show:not(.vsc-hidden),
.vsc-controller.vsc-idle-hidden.vsc-show:not(.vsc-hidden),
.vsc-controller.vsc-forced-show:not(.vsc-hidden) {
visibility: visible !important;
opacity: 1 !important;
}
/* YouTube embedded player */
/* e.g. https://www.igvita.com/2012/09/12/web-fonts-performance-making-pretty-fast/ */
.html5-video-player:not(.ytp-hide-info-bar) .vsc-controller {
position: relative;
top: 60px;
}
/* Facebook player */
#facebook .vsc-controller {
position: relative;
top: 40px;
}
/* Google Photos player */
/* Inline preview doesn't have any additional hooks, relying on Aria label */
a[aria-label^="Video"] .vsc-controller {
position: relative;
top: 35px;
}
/* Google Photos full-screen view */
#player .house-brand .vsc-controller {
position: relative;
top: 50px;
}
/* Netflix player */
#netflix-player:not(.player-cinema-mode) .vsc-controller {
position: relative;
top: 85px;
}
/* shift controller on vine.co */
/* e.g. https://vine.co/v/OrJj39YlL57 */
.video-container .vine-video-container .vsc-controller {
margin-left: 40px;
}
/* shift YT 3D controller down */
/* e.g. https://www.youtube.com/watch?v=erftYPflJzQ */
.ytp-webgl-spherical-control {
top: 60px !important;
}
.ytp-fullscreen .ytp-webgl-spherical-control {
top: 100px !important;
}
/* Original overrides removed to avoid interference with the new anchoring system.
The .vsc-controller wrapper now fills the player container exactly,
allowing #controller (inside shadow root) to anchor correctly to all 8 positions. */
/* disable Vimeo video overlay */
div.video-wrapper + div.target {
+2590 -738
View File
@@ -1,35 +1,1105 @@
var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
var isUserSeek = false; // Track if seek was user-initiated
var lastToggleSpeed = {}; // Store last toggle speeds per video
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var controllerUtils = speederShared.controllerUtils || {};
var keyBindingUtils = speederShared.keyBindings || {};
var siteRuleUtils = speederShared.siteRules || {};
function getPrimaryVideoElement() {
if (!tc.mediaElements || tc.mediaElements.length === 0) return null;
for (var i = 0; i < tc.mediaElements.length; i++) {
var el = tc.mediaElements[i];
if (el && !el.paused) return el;
}
return tc.mediaElements[0];
}
var tc = {
settings: {
lastSpeed: 1.0,
enabled: true,
speeds: {},
displayKeyCode: 86,
rememberSpeed: false,
forceLastSavedSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
keyBindings: [],
blacklist: `\
www.instagram.com
twitter.com
vine.co
imgur.com
teams.microsoft.com
`.replace(regStrip, ""),
defaultLogLevel: 4,
siteRules: [],
controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
defaultLogLevel: 3,
logLevel: 3,
// --- Nudge settings (ADDED) ---
enableSubtitleNudge: true,
subtitleNudgeInterval: 25,
subtitleNudgeAmount: 0.001
enableSubtitleNudge: false,
subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost
subtitleNudgeAmount: 0.001,
customButtonIcons: {}
},
mediaElements: [],
isNudging: false // ADDED: Flag for nudge operation
isNudging: false,
pendingLastSpeedSave: null,
pendingLastSpeedValue: null,
persistedLastSpeed: 1.0,
activeSiteRule: null,
siteRuleBase: null
};
var MIN_SPEED = 0.0625;
var MAX_SPEED = 16;
var YT_NATIVE_MIN = 0.25;
var YT_NATIVE_MAX = 2.0;
var YT_NATIVE_STEP = 0.05;
var vscObservedRoots = new WeakSet();
var requestIdle =
typeof window.requestIdleCallback === "function"
? window.requestIdleCallback.bind(window)
: function(callback, options) {
return setTimeout(callback, (options && options.timeout) || 1);
};
var controllerLocations = Array.isArray(controllerUtils.controllerLocations)
? controllerUtils.controllerLocations.slice()
: [
"top-left",
"top-center",
"top-right",
"middle-right",
"bottom-right",
"bottom-center",
"bottom-left",
"middle-left"
];
var defaultControllerLocation =
controllerUtils.defaultControllerLocation || controllerLocations[0];
var controllerLocationStyles = {
"top-left": {
top: "10px",
left: "15px",
transform: "translate(0, 0)"
},
"top-center": {
top: "10px",
left: "50%",
transform: "translate(-50%, 0)"
},
"top-right": {
top: "10px",
left: "calc(100% - 10px)",
transform: "translate(-100%, 0)"
},
"middle-right": {
top: "50%",
left: "calc(100% - 10px)",
transform: "translate(-100%, -50%)"
},
"bottom-right": {
top: "calc(100% - 0px)",
left: "calc(100% - 10px)",
transform: "translate(-100%, -100%)"
},
"bottom-center": {
top: "calc(100% - 0px)",
left: "50%",
transform: "translate(-50%, -100%)"
},
"bottom-left": {
top: "calc(100% - 0px)",
left: "15px",
transform: "translate(0, -100%)"
},
"middle-left": {
top: "50%",
left: "15px",
transform: "translate(0, -50%)"
}
};
/* `label` fallback only when ui-icons has no path for the action. */
var controllerButtonDefs = {
rewind: { label: "", className: "rw" },
slower: { label: "", className: "" },
faster: { label: "", className: "" },
advance: { label: "", className: "rw" },
display: { label: "", className: "hideButton" },
reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" },
nudge: { label: "", className: "" },
pause: { label: "", className: "" },
muted: { label: "", className: "" },
louder: { label: "", className: "" },
softer: { label: "", className: "" },
mark: { label: "", className: "" },
jump: { label: "", className: "" },
settings: { label: "", className: "" }
};
function createDefaultBinding(action, code, value) {
return {
action: action,
code: code,
value: value,
force: false,
predefined: true
};
}
function defaultKeyBindings(storage) {
return [
createDefaultBinding(
"slower",
"KeyS",
Number(storage.speedStep) || 0.1
),
createDefaultBinding(
"faster",
"KeyD",
Number(storage.speedStep) || 0.1
),
createDefaultBinding(
"rewind",
"KeyZ",
Number(storage.rewindTime) || 10
),
createDefaultBinding(
"advance",
"KeyX",
Number(storage.advanceTime) || 10
),
createDefaultBinding(
"reset",
"KeyR",
1.0
),
createDefaultBinding(
"fast",
"KeyG",
Number(storage.fastSpeed) || 1.8
),
createDefaultBinding(
"move",
"KeyP",
0
),
createDefaultBinding(
"toggleSubtitleNudge",
"KeyN",
0
)
];
}
function ensureDefaultKeyBinding(action, code, value) {
if (tc.settings.keyBindings.some((binding) => binding.action === action)) {
return false;
}
tc.settings.keyBindings.push(
createDefaultBinding(action, code, value)
);
return true;
}
function getLegacyKeyCode(binding) {
return keyBindingUtils.getLegacyKeyCode(binding);
}
function normalizeControllerLocation(location) {
return controllerUtils.normalizeControllerLocation(
location,
defaultControllerLocation
);
}
var CONTROLLER_MARGIN_MAX_PX = 200;
function normalizeControllerMarginPx(value, fallback) {
return controllerUtils.clampControllerMarginPx(value, fallback);
}
function applyControllerMargins(controller) {
if (!controller) return;
var d = tc.settings;
var loc = controller.dataset.location;
var manual = controller.dataset.positionMode === "manual";
var isTopAnchored =
!manual &&
(loc === "top-left" ||
loc === "top-center" ||
loc === "top-right");
var isBottomAnchored =
!manual &&
(loc === "bottom-right" ||
loc === "bottom-center" ||
loc === "bottom-left");
var isMiddleRow =
!manual && (loc === "middle-left" || loc === "middle-right");
var mt = normalizeControllerMarginPx(d.controllerMarginTop, 0);
var mb = normalizeControllerMarginPx(d.controllerMarginBottom, 65);
if (isTopAnchored || isBottomAnchored || isMiddleRow) {
mt = 0;
mb = 0;
}
controller.style.marginTop = mt + "px";
var ml = normalizeControllerMarginPx(d.controllerMarginLeft, 0);
var mr = normalizeControllerMarginPx(d.controllerMarginRight, 0);
if (!manual) {
ml = 0;
mr = 0;
}
controller.style.marginRight = mr + "px";
controller.style.marginBottom = mb + "px";
controller.style.marginLeft = ml + "px";
}
function getNextControllerLocation(location) {
return controllerUtils.getNextControllerLocation(location);
}
function getControllerElement(videoOrController) {
if (!videoOrController) return null;
if (
videoOrController.shadowRoot &&
typeof videoOrController.shadowRoot.querySelector === "function"
) {
return videoOrController.shadowRoot.querySelector("#controller");
}
if (
videoOrController.div &&
videoOrController.div.shadowRoot &&
typeof videoOrController.div.shadowRoot.querySelector === "function"
) {
return videoOrController.div.shadowRoot.querySelector("#controller");
}
return null;
}
function applyControllerLocationToElement(controller, location) {
if (!controller) return defaultControllerLocation;
var normalizedLocation = normalizeControllerLocation(location);
var styles = controllerLocationStyles[normalizedLocation];
controller.dataset.location = normalizedLocation;
controller.dataset.positionMode = "anchored";
var top = styles.top;
if (
normalizedLocation === "top-left" ||
normalizedLocation === "top-center" ||
normalizedLocation === "top-right"
) {
var insetTop = normalizeControllerMarginPx(
tc.settings.controllerMarginTop,
0
);
top = "calc(10px + " + insetTop + "px)";
}
if (
normalizedLocation === "bottom-right" ||
normalizedLocation === "bottom-center" ||
normalizedLocation === "bottom-left"
) {
var lift = normalizeControllerMarginPx(
tc.settings.controllerMarginBottom,
65
);
top = "calc(100% - " + lift + "px)";
}
// If in fullscreen, move the controller down to avoid overlapping video titles
if (
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
) {
if (normalizedLocation.startsWith("top-")) {
var insetTopFs = normalizeControllerMarginPx(
tc.settings.controllerMarginTop,
0
);
top = "calc(63px + " + insetTopFs + "px)";
}
}
controller.style.top = top;
var left = styles.left;
switch (normalizedLocation) {
case "top-left":
case "middle-left":
case "bottom-left":
left = "15px";
break;
case "top-right":
case "middle-right":
case "bottom-right":
left = "calc(100% - 10px)";
break;
case "top-center":
case "bottom-center":
left = "50%";
break;
default:
break;
}
controller.style.left = left;
controller.style.transform = styles.transform;
applyControllerMargins(controller);
return normalizedLocation;
}
function applyControllerLocation(videoController, location) {
if (!videoController) return;
var controller = getControllerElement(videoController);
if (!controller) return;
videoController.controllerLocation = applyControllerLocationToElement(
controller,
location
);
}
function captureSiteRuleBase() {
tc.siteRuleBase = {
startHidden: tc.settings.startHidden,
hideWithControls: tc.settings.hideWithControls,
hideWithControlsTimer: tc.settings.hideWithControlsTimer,
controllerLocation: tc.settings.controllerLocation,
rememberSpeed: tc.settings.rememberSpeed,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
audioBoolean: tc.settings.audioBoolean,
controllerOpacity: tc.settings.controllerOpacity,
controllerMarginTop: tc.settings.controllerMarginTop,
controllerMarginBottom: tc.settings.controllerMarginBottom,
enableSubtitleNudge: tc.settings.enableSubtitleNudge,
subtitleNudgeInterval: tc.settings.subtitleNudgeInterval,
controllerButtons: Array.isArray(tc.settings.controllerButtons)
? tc.settings.controllerButtons.slice()
: tc.settings.controllerButtons,
keyBindings: Array.isArray(tc.settings.keyBindings)
? tc.settings.keyBindings.map(function(binding) {
return Object.assign({}, binding);
})
: tc.settings.keyBindings
};
}
function resetSettingsFromSiteRuleBase() {
if (!tc.siteRuleBase) return;
var base = tc.siteRuleBase;
tc.settings.startHidden = base.startHidden;
tc.settings.hideWithControls = base.hideWithControls;
tc.settings.hideWithControlsTimer = base.hideWithControlsTimer;
tc.settings.controllerLocation = base.controllerLocation;
tc.settings.rememberSpeed = base.rememberSpeed;
tc.settings.forceLastSavedSpeed = base.forceLastSavedSpeed;
tc.settings.audioBoolean = base.audioBoolean;
tc.settings.controllerOpacity = base.controllerOpacity;
tc.settings.controllerMarginTop = base.controllerMarginTop;
tc.settings.controllerMarginBottom = base.controllerMarginBottom;
tc.settings.enableSubtitleNudge = base.enableSubtitleNudge;
tc.settings.subtitleNudgeInterval = base.subtitleNudgeInterval;
tc.settings.controllerButtons = Array.isArray(base.controllerButtons)
? base.controllerButtons.slice()
: base.controllerButtons;
tc.settings.keyBindings = Array.isArray(base.keyBindings)
? base.keyBindings.map(function(binding) {
return Object.assign({}, binding);
})
: base.keyBindings;
}
function clearManualControllerPosition(videoController) {
if (!videoController) return;
applyControllerLocation(
videoController,
videoController.controllerLocation || tc.settings.controllerLocation
);
}
function convertControllerToManualPosition(videoController) {
if (!videoController) return null;
var controller = getControllerElement(videoController);
if (!controller) return null;
controller.dataset.positionMode = "manual";
var offsetParent = controller.offsetParent;
if (offsetParent) {
var controllerRect = controller.getBoundingClientRect();
var offsetParentRect = offsetParent.getBoundingClientRect();
controller.style.setProperty(
"left",
controllerRect.left - offsetParentRect.left + "px",
"important"
);
controller.style.setProperty(
"top",
controllerRect.top - offsetParentRect.top + "px",
"important"
);
} else {
controller.style.setProperty(
"left",
controller.offsetLeft + "px",
"important"
);
controller.style.setProperty(
"top",
controller.offsetTop + "px",
"important"
);
}
controller.style.setProperty("transform", "none", "important");
return controller;
}
function cycleControllerLocation(video) {
if (!video || !video.vsc) return;
video.vsc.controllerLocation = getNextControllerLocation(
video.vsc.controllerLocation || tc.settings.controllerLocation
);
clearManualControllerPosition(video.vsc);
}
function normalizeBindingKey(key) {
return keyBindingUtils.normalizeBindingKey(key);
}
function legacyBindingKeyToCode(key) {
return keyBindingUtils.legacyBindingKeyToCode(key);
}
function legacyKeyCodeToCode(keyCode) {
return keyBindingUtils.legacyKeyCodeToCode(keyCode);
}
function inferBindingCode(binding, fallbackCode) {
return keyBindingUtils.inferBindingCode(binding, fallbackCode);
}
function normalizeStoredBinding(binding, fallbackCode) {
if (!binding) {
if (!fallbackCode) return null;
return {
code: fallbackCode,
disabled: false,
value: 0,
force: "false",
predefined: false
};
}
if (
binding.disabled === true ||
(binding.code === null &&
binding.key === null &&
binding.keyCode === null)
) {
return {
action: binding.action,
code: null,
disabled: true,
value: Number(binding.value),
force: String(binding.force) === "true" ? "true" : "false",
predefined: Boolean(binding.predefined)
};
}
var normalizedCode = inferBindingCode(binding, fallbackCode);
if (!normalizedCode) {
return null;
}
var normalized = {
action: binding.action,
code: normalizedCode,
disabled: false,
value: Number(binding.value),
force: String(binding.force) === "true" ? "true" : "false",
predefined: Boolean(binding.predefined)
};
return normalized;
}
function isValidSpeed(speed) {
return !isNaN(speed) && speed >= MIN_SPEED && speed <= MAX_SPEED;
}
function sanitizeSpeed(speed, fallback) {
var numericSpeed = Number(speed);
return isValidSpeed(numericSpeed) ? numericSpeed : fallback;
}
function getVideoSourceKey(video) {
return (video && (video.currentSrc || video.src)) || "unknown_src";
}
function getControllerTargetSpeed(video) {
if (!video || !video.vsc) return null;
if (!isValidSpeed(video.vsc.targetSpeed)) return null;
var currentSourceKey = getVideoSourceKey(video);
var targetSourceKey = video.vsc.targetSpeedSourceKey;
// SPA sites (e.g. YouTube) can reuse the same <video> element.
// Don't carry controller target speed across a source swap.
if (
targetSourceKey &&
currentSourceKey === "unknown_src" &&
targetSourceKey !== "unknown_src"
) {
return null;
}
if (
targetSourceKey &&
currentSourceKey !== "unknown_src" &&
targetSourceKey !== currentSourceKey
) {
return null;
}
return video.vsc.targetSpeed;
}
function getRememberedSpeed(video) {
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
return null;
}
var sourceKey = getVideoSourceKey(video);
if (sourceKey !== "unknown_src") {
var videoSpeed = tc.settings.speeds[sourceKey];
if (isValidSpeed(videoSpeed)) return videoSpeed;
}
if (tc.settings.forceLastSavedSpeed && isValidSpeed(tc.settings.lastSpeed)) {
return tc.settings.lastSpeed;
}
if (tc.settings.rememberSpeed && isValidSpeed(tc.settings.lastSpeed)) {
return tc.settings.lastSpeed;
}
return null;
}
function getDesiredSpeed(video) {
return getControllerTargetSpeed(video) || getRememberedSpeed(video) || 1.0;
}
function isOnYouTube() {
return (
location.hostname.includes("youtube.com") ||
location.hostname.includes("youtube-nocookie.com")
);
}
function isYouTubeNativeSpeedRange(speed) {
if (speed < YT_NATIVE_MIN || speed > YT_NATIVE_MAX) return false;
// Check if speed is a multiple of 0.05 (with floating point tolerance)
var remainder = Math.abs((speed * 100) % (YT_NATIVE_STEP * 100));
return remainder < 0.01 || remainder > (YT_NATIVE_STEP * 100 - 0.01);
}
function tryYouTubeNativeSpeed(video, speed) {
if (!isOnYouTube() || !isYouTubeNativeSpeedRange(speed)) return false;
try {
// YouTube's movie_player element exposes setPlaybackRate() but it's a
// page-level JS method, not a native DOM method. Content scripts can't
// see it directly. In Firefox, wrappedJSObject gives access to the
// page's JS context.
var playerEl =
video.closest(".html5-video-player") ||
document.getElementById("movie_player");
if (!playerEl) return false;
// Try wrappedJSObject first (Firefox content script → page context)
var player = playerEl.wrappedJSObject || playerEl;
if (typeof player.setPlaybackRate === "function") {
player.setPlaybackRate(speed);
// Verify YouTube actually accepted the speed (it may silently clamp)
var actualSpeed = video.playbackRate;
if (Math.abs(actualSpeed - speed) > 0.01) {
log("YouTube clamped speed to " + actualSpeed + ", falling back", 4);
return false;
}
log("Used YouTube native setPlaybackRate: " + speed, 4);
return true;
}
} catch (e) {
log("YouTube native speed failed: " + e.message, 3);
}
return false;
}
function isSubtitleNudgeSupported(video) {
return Boolean(video);
}
function isSubtitleNudgeAvailableForVideo(video) {
return isSubtitleNudgeSupported(video) && Boolean(tc.settings.enableSubtitleNudge);
}
function isSubtitleNudgeEnabledForVideo(video) {
if (!isSubtitleNudgeAvailableForVideo(video)) return false;
if (!video || !video.vsc) return true;
if (typeof video.vsc.subtitleNudgeEnabledOverride === "boolean") {
return video.vsc.subtitleNudgeEnabledOverride;
}
return true;
}
function setSubtitleNudgeEnabledForVideo(video, enabled) {
if (!video || !video.vsc) return false;
if (!isSubtitleNudgeAvailableForVideo(video)) {
video.vsc.subtitleNudgeEnabledOverride = null;
video.vsc.stopSubtitleNudge();
updateSubtitleNudgeIndicator(video);
return false;
}
var normalizedEnabled = Boolean(enabled);
video.vsc.subtitleNudgeEnabledOverride = normalizedEnabled;
if (!normalizedEnabled) {
video.vsc.stopSubtitleNudge();
} else if (!video.paused && video.playbackRate !== 1.0) {
video.vsc.startSubtitleNudge();
}
updateSubtitleNudgeIndicator(video);
// Briefly flash the standalone indicator next to the speed text
var flashEl = video.vsc.nudgeFlashIndicator;
if (flashEl) {
flashEl.classList.add("visible");
clearTimeout(flashEl._flashTimer);
flashEl._flashTimer = setTimeout(function() {
flashEl.classList.remove("visible");
}, 1500);
}
return normalizedEnabled;
}
function renderSubtitleNudgeIndicatorContent(target, isEnabled) {
if (!target) return;
var action = isEnabled ? "subtitleNudgeOn" : "subtitleNudgeOff";
var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg;
vscClearElement(target);
if (custom) {
var customWrap = vscCreateSvgWrap(
target.ownerDocument || document,
custom,
"vsc-btn-icon"
);
if (customWrap) {
target.appendChild(customWrap);
return;
}
}
if (typeof vscIconSvgString !== "function") {
target.textContent = isEnabled ? "✓" : "×";
return;
}
var svg = vscIconSvgString(action, 14);
if (!svg) {
target.textContent = isEnabled ? "✓" : "×";
return;
}
var wrap = vscCreateSvgWrap(target.ownerDocument || document, svg, "vsc-btn-icon");
if (wrap) {
target.appendChild(wrap);
return;
}
target.textContent = isEnabled ? "✓" : "×";
}
function updateSubtitleNudgeIndicator(video) {
if (!video || !video.vsc) return;
var isAvailable = isSubtitleNudgeAvailableForVideo(video);
var isEnabled = isSubtitleNudgeEnabledForVideo(video);
var title = !isAvailable
? "Subtitle nudge unavailable on this site"
: isEnabled
? "Subtitle nudge enabled"
: "Subtitle nudge disabled";
var indicator = video.vsc.subtitleNudgeIndicator;
if (indicator) {
renderSubtitleNudgeIndicatorContent(indicator, isEnabled);
indicator.dataset.enabled = isEnabled ? "true" : "false";
indicator.dataset.supported = isAvailable ? "true" : "false";
indicator.title = title;
indicator.setAttribute("aria-label", title);
}
var flashEl = video.vsc.nudgeFlashIndicator;
if (flashEl) {
renderSubtitleNudgeIndicatorContent(flashEl, isEnabled);
flashEl.dataset.enabled = isEnabled ? "true" : "false";
flashEl.dataset.supported = isAvailable ? "true" : "false";
flashEl.setAttribute("aria-label", title);
}
}
function schedulePersistLastSpeed(speed) {
if (!isValidSpeed(speed)) return;
tc.pendingLastSpeedValue = speed;
if (tc.pendingLastSpeedSave !== null) return;
tc.pendingLastSpeedSave = setTimeout(function() {
var speedToPersist = tc.pendingLastSpeedValue;
tc.pendingLastSpeedSave = null;
if (!isValidSpeed(speedToPersist) || tc.persistedLastSpeed === speedToPersist) {
return;
}
chrome.storage.sync.set({ lastSpeed: speedToPersist }, function() {
});
tc.persistedLastSpeed = speedToPersist;
}, 250);
}
function suppressNextNudgeRateChanges(controller, count) {
if (!controller) return;
controller.suppressedRateChangeCount =
(controller.suppressedRateChangeCount || 0) + (count || 2);
controller.suppressedRateChangeUntil =
Date.now() + Math.max(250, tc.settings.subtitleNudgeInterval * 4);
}
function shouldIgnoreSuppressedRateChange(video) {
if (!video || !video.vsc) return false;
var controller = video.vsc;
if (
controller.suppressedRateChangeCount > 0 &&
controller.suppressedRateChangeUntil >= Date.now()
) {
controller.suppressedRateChangeCount -= 1;
return true;
}
if (controller.suppressedRateChangeUntil < Date.now()) {
controller.suppressedRateChangeCount = 0;
}
return false;
}
function resolveTargetSpeed(video) {
return getDesiredSpeed(video);
}
function applySourceTransitionPolicy(video, forceUpdate) {
if (!video || !video.vsc) return;
var sourceKey = getVideoSourceKey(video);
if (!forceUpdate && video.vsc.mediaSourceKey === sourceKey) return;
video.vsc.mediaSourceKey = sourceKey;
var desiredSpeed =
tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed
? sanitizeSpeed(tc.settings.lastSpeed, 1.0)
: 1.0;
video.vsc.targetSpeed = desiredSpeed;
video.vsc.targetSpeedSourceKey = sourceKey;
if (video.vsc.speedIndicator) {
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
}
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
setSpeed(video, desiredSpeed, false, false);
}
// Same-tab SPA (e.g. YouTube watch → Shorts): URL can change while remember-speed
// already ran on src mutation — re-apply margins / location / opacity for new rules.
reapplySiteRulesAndControllerGeometry();
}
function extendSpeedRestoreWindow(video, duration) {
if (!video || !video.vsc) return;
var restoreDuration = Number(duration) || 1500;
var restoreUntil = Date.now() + restoreDuration;
var currentUntil = Number(video.vsc.speedRestoreUntil) || 0;
video.vsc.speedRestoreUntil = Math.max(currentUntil, restoreUntil);
}
function scheduleSpeedRestore(video, desiredSpeed, reason) {
if (!video || !video.vsc || !isValidSpeed(desiredSpeed)) return;
if (video.vsc.restoreSpeedTimer) {
clearTimeout(video.vsc.restoreSpeedTimer);
}
video.vsc.restoreSpeedTimer = setTimeout(function() {
if (!video.vsc) return;
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
log(
`Restoring playbackRate to ${desiredSpeed.toFixed(2)} after ${reason}`,
4
);
setSpeed(video, desiredSpeed, false, false);
}
if (video.vsc) {
video.vsc.restoreSpeedTimer = null;
}
}, 0);
}
function rememberPendingRateChange(video, speed) {
if (!video || !video.vsc || !isValidSpeed(speed)) return;
video.vsc.pendingRateChange = {
speed: Number(speed),
expiresAt: Date.now() + 1000
};
}
function takePendingRateChange(video, currentSpeed) {
if (!video || !video.vsc || !video.vsc.pendingRateChange) return null;
var pendingRateChange = video.vsc.pendingRateChange;
if (
!isValidSpeed(pendingRateChange.speed) ||
pendingRateChange.expiresAt <= Date.now()
) {
video.vsc.pendingRateChange = null;
return null;
}
if (Math.abs(Number(pendingRateChange.speed) - currentSpeed) > 0.01) {
return null;
}
video.vsc.pendingRateChange = null;
return pendingRateChange;
}
function matchesKeyBinding(binding, event) {
return Boolean(
binding &&
binding.disabled !== true &&
typeof binding.code === "string" &&
binding.code.length > 0 &&
binding.code === event.code
);
}
function mediaSelector() {
return tc.settings.audioBoolean ? "video,audio" : "video";
}
function isMediaElement(node) {
return (
node &&
node.nodeType === Node.ELEMENT_NODE &&
(node.nodeName === "VIDEO" ||
(node.nodeName === "AUDIO" && tc.settings.audioBoolean))
);
}
function hasUsableMediaSource(node) {
if (!isMediaElement(node) || !node.isConnected) return false;
if (node.currentSrc || node.src || node.srcObject) return true;
if (typeof node.readyState === "number" && node.readyState > 0) return true;
if (
typeof node.networkState === "number" &&
typeof HTMLMediaElement !== "undefined" &&
(node.networkState === HTMLMediaElement.NETWORK_IDLE ||
node.networkState === HTMLMediaElement.NETWORK_LOADING)
) {
return true;
}
if (node.querySelectorAll) {
return Array.from(node.querySelectorAll("source[src]")).some(function(
source
) {
var src = source.getAttribute("src");
return typeof src === "string" && src.trim().length > 0;
});
}
return false;
}
function ensureController(node, parent) {
if (!isMediaElement(node) || node.vsc) return node && node.vsc;
if (!hasUsableMediaSource(node)) {
log(
`Deferring controller creation for ${node.tagName}: no usable source yet`,
5
);
return null;
}
// href selects site rules; re-run on every new/usable media so margins/opacity match current URL.
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
return null;
}
refreshAllControllerGeometry();
log(
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
4
);
node.vsc = new tc.videoController(
node,
parent || node.parentElement || node.parentNode
);
return node.vsc;
}
function removeController(node) {
if (node && node.vsc) node.vsc.remove();
}
function scanNodeForMedia(node, parent, added) {
if (!node || typeof node === "function") return;
if (node.nodeType === Node.DOCUMENT_NODE) {
scanNodeForMedia(node.body || node.documentElement, node.body, added);
return;
}
if (
node.nodeType !== Node.ELEMENT_NODE &&
node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
) {
return;
}
var ownerDocument = node.ownerDocument || document;
if (!added && ownerDocument.body && ownerDocument.body.contains(node)) return;
// Check if the node itself is a media element
if (isMediaElement(node)) {
if (added) ensureController(node, parent);
else removeController(node);
}
// Use querySelectorAll instead of recursive child walking — the browser's
// native selector engine is orders of magnitude faster than JS recursion.
if (typeof node.querySelectorAll === "function") {
var selector = mediaSelector();
try {
var mediaElements = node.querySelectorAll(selector);
for (var i = 0; i < mediaElements.length; i++) {
var el = mediaElements[i];
if (added) ensureController(el, el.parentNode || parent);
else removeController(el);
}
} catch (e) {
// querySelectorAll may throw on detached or unusual nodes
}
}
// Still need to observe shadow roots for media inside web components
if (node.shadowRoot) {
observeRoot(node.shadowRoot);
}
}
function getScanNodeForRoot(root) {
if (!root) return null;
if (root.nodeType === Node.DOCUMENT_NODE) {
return root.body || root.documentElement;
}
return root;
}
function rootMayContainMedia(root) {
if (!root) return false;
if (root.nodeType === Node.DOCUMENT_NODE) return true;
if (typeof root.querySelector !== "function") return true;
try {
return Boolean(root.querySelector(mediaSelector()));
} catch (error) {
return true;
}
}
function scanRootForMedia(root) {
var scanRoot = getScanNodeForRoot(root);
if (!scanRoot) return;
scanNodeForMedia(scanRoot, root.host || scanRoot.parentNode || scanRoot, true);
if (root.nodeType === Node.DOCUMENT_NODE) {
attachIframeListeners(root);
}
}
function observeRoot(root) {
if (!root || vscObservedRoots.has(root)) return;
vscObservedRoots.add(root);
setupListener(root);
attachMutationObserver(root);
attachMediaDetectionListeners(root);
if (rootMayContainMedia(root)) {
scanRootForMedia(root);
}
}
function patchAttachShadow() {
if (
window.vscAttachShadowPatched ||
typeof Element === "undefined" ||
typeof Element.prototype.attachShadow !== "function"
) {
return;
}
var originalAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function() {
var shadowRoot = originalAttachShadow.apply(this, arguments);
try {
if (shadowRoot) {
observeRoot(shadowRoot);
}
} catch (error) {
log(`Unable to observe shadow root: ${error.message}`, 3);
}
return shadowRoot;
};
window.vscAttachShadowPatched = true;
}
/* Log levels */
function log(message, level) {
verbosity = tc.settings.logLevel;
@@ -47,213 +1117,367 @@ function log(message, level) {
}
}
chrome.storage.sync.get(tc.settings, function (storage) {
tc.settings.keyBindings =
Array.isArray(storage.keyBindings) &&
storage.keyBindings.length > 0 &&
storage.keyBindings[0].hasOwnProperty("predefined")
? storage.keyBindings
: [
{
action: "slower",
key: Number(storage.slowerKeyCode) || 83,
value: Number(storage.speedStep) || 0.1,
force: false,
predefined: true
},
{
action: "faster",
key: Number(storage.fasterKeyCode) || 68,
value: Number(storage.speedStep) || 0.1,
force: false,
predefined: true
},
{
action: "rewind",
key: Number(storage.rewindKeyCode) || 90,
value: Number(storage.rewindTime) || 10,
force: false,
predefined: true
},
{
action: "advance",
key: Number(storage.advanceKeyCode) || 88,
value: Number(storage.advanceTime) || 10,
force: false,
predefined: true
},
{
action: "reset",
key: Number(storage.resetKeyCode) || 82,
value: 1.0,
force: false,
predefined: true
},
{
action: "fast",
key: Number(storage.fastKeyCode) || 71,
value: Number(storage.fastSpeed) || 1.8,
force: false,
predefined: true
}
];
if (
!Array.isArray(storage.keyBindings) ||
storage.keyBindings.length === 0 ||
(storage.keyBindings.length > 0 &&
!storage.keyBindings[0].hasOwnProperty("predefined"))
) {
chrome.storage.sync.get(tc.settings, function(storage) {
var storedBindings = Array.isArray(storage.keyBindings)
? storage.keyBindings
: [];
tc.settings.keyBindings = storedBindings
.map((binding) => normalizeStoredBinding(binding))
.filter(Boolean);
if (tc.settings.keyBindings.length === 0) {
tc.settings.keyBindings = defaultKeyBindings(storage);
tc.settings.version = "0.5.3";
chrome.storage.sync.set({
keyBindings: tc.settings.keyBindings,
version: "0.6.3.14"
}); // Incremented version
version: tc.settings.version,
rememberSpeed: tc.settings.rememberSpeed,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
audioBoolean: tc.settings.audioBoolean,
startHidden: tc.settings.startHidden,
enabled: tc.settings.enabled,
controllerLocation: tc.settings.controllerLocation,
controllerOpacity: tc.settings.controllerOpacity
});
}
tc.settings.lastSpeed = Number(storage.lastSpeed) || 1.0;
tc.settings.displayKeyCode = Number(storage.displayKeyCode) || 86;
tc.settings.lastSpeed = Number(storage.lastSpeed);
if (!isValidSpeed(tc.settings.lastSpeed) && tc.settings.lastSpeed !== 1.0) {
log(`Invalid lastSpeed detected: ${storage.lastSpeed}, resetting to 1.0`, 3);
tc.settings.lastSpeed = 1.0;
tc.persistedLastSpeed = 1.0;
chrome.storage.sync.set({ lastSpeed: 1.0 });
} else if (!isValidSpeed(tc.settings.lastSpeed)) {
tc.settings.lastSpeed = 1.0;
}
tc.persistedLastSpeed = tc.settings.lastSpeed;
tc.settings.rememberSpeed = Boolean(storage.rememberSpeed);
tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed);
tc.settings.audioBoolean = Boolean(storage.audioBoolean);
tc.settings.enabled =
typeof storage.enabled !== "undefined" ? Boolean(storage.enabled) : true;
tc.settings.enabled = Boolean(storage.enabled);
tc.settings.startHidden = Boolean(storage.startHidden);
tc.settings.controllerOpacity = Number(storage.controllerOpacity) || 0.3;
tc.settings.blacklist = String(storage.blacklist || tc.settings.blacklist);
if (typeof storage.logLevel !== "undefined")
tc.settings.logLevel = Number(storage.logLevel);
tc.settings.hideWithControls =
typeof storage.hideWithControls !== "undefined"
? Boolean(storage.hideWithControls)
: Boolean(storage.hideWithYouTubeControls);
tc.settings.hideWithControlsTimer =
Math.min(15, Math.max(0.1, Number(storage.hideWithControlsTimer) || 2.0));
tc.settings.hideWithYouTubeControls = tc.settings.hideWithControls;
tc.settings.controllerLocation = normalizeControllerLocation(
storage.controllerLocation
);
tc.settings.controllerOpacity = Number(storage.controllerOpacity);
tc.settings.controllerMarginTop = normalizeControllerMarginPx(
storage.controllerMarginTop,
0
);
tc.settings.controllerMarginRight = normalizeControllerMarginPx(
0,
0
);
tc.settings.controllerMarginBottom = normalizeControllerMarginPx(
storage.controllerMarginBottom,
typeof storage.controllerMarginBottom !== "undefined" ? 0 : 65
);
tc.settings.controllerMarginLeft = normalizeControllerMarginPx(
0,
0
);
tc.settings.siteRules = Array.isArray(storage.siteRules)
? storage.siteRules
: [];
tc.settings.controllerButtons = Array.isArray(storage.controllerButtons)
? storage.controllerButtons
: tc.settings.controllerButtons;
tc.settings.enableSubtitleNudge =
typeof storage.enableSubtitleNudge !== "undefined"
? Boolean(storage.enableSubtitleNudge)
: tc.settings.enableSubtitleNudge;
tc.settings.subtitleNudgeInterval =
Number(storage.subtitleNudgeInterval) || 25;
tc.settings.subtitleNudgeInterval = Math.min(
1000,
Math.max(10, Number(storage.subtitleNudgeInterval) || 50)
);
tc.settings.subtitleNudgeAmount =
Number(storage.subtitleNudgeAmount) || tc.settings.subtitleNudgeAmount;
var addedDefaultBinding = false;
addedDefaultBinding =
ensureDefaultKeyBinding(
"display",
"KeyV",
0
) || addedDefaultBinding;
addedDefaultBinding =
ensureDefaultKeyBinding("move", "KeyP", 0) || addedDefaultBinding;
addedDefaultBinding =
ensureDefaultKeyBinding("toggleSubtitleNudge", "KeyN", 0) ||
addedDefaultBinding;
if (
tc.settings.keyBindings.filter((x) => x.action == "display").length == 0
) {
tc.settings.keyBindings.push({
action: "display",
key: tc.settings.displayKeyCode,
value: 0,
force: false,
predefined: true
});
if (addedDefaultBinding) {
chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings });
}
initializeWhenReady(document);
captureSiteRuleBase();
patchAttachShadow();
// Add a listener for messages from the popup.
// We use a global flag to ensure the listener is only attached once.
if (!window.vscMessageListener) {
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.action === "rescan_page") {
log("Re-scan command received from popup.", 4);
initializeWhenReady(document, true);
sendResponse({ status: "complete" });
return false;
}
if (request.action === "get_speed") {
// Do not sendResponse in frames with no media — only one response is
// accepted tab-wide, and the top frame often wins before an iframe.
var videoGs = getPrimaryVideoElement();
if (!videoGs) return false;
sendResponse({
speed: videoGs.playbackRate
});
return false;
}
if (request.action === "get_page_context") {
sendResponse({ url: location.href });
return false;
}
if (request.action === "run_action") {
var value = request.value;
if (value === undefined || value === null) {
value = getKeyBindings(request.actionName, "value");
}
runAction(request.actionName, value);
var videoAfter = getPrimaryVideoElement();
if (!videoAfter) return false;
sendResponse({
speed: videoAfter.playbackRate
});
return false;
}
return false;
}
);
// Set the flag to prevent adding the listener again.
window.vscMessageListener = true;
}
chrome.storage.local.get(["customButtonIcons"], function(loc) {
tc.settings.customButtonIcons =
loc &&
loc.customButtonIcons &&
typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
if (!window.vscCustomIconListener) {
window.vscCustomIconListener = true;
chrome.storage.onChanged.addListener(function(changes, area) {
if (area !== "local" || !changes.customButtonIcons) return;
var nv = changes.customButtonIcons.newValue;
tc.settings.customButtonIcons =
nv && typeof nv === "object" ? nv : {};
if (tc.mediaElements && tc.mediaElements.length) {
tc.mediaElements.forEach(function(video) {
if (!video.vsc || !video.vsc.div) return;
var doc = video.ownerDocument;
var shadow = video.vsc.div.shadowRoot;
if (!shadow) return;
shadow.querySelectorAll("button[data-action]").forEach(function(btn) {
var act = btn.dataset.action;
if (!act) return;
var svg =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[act] &&
tc.settings.customButtonIcons[act].svg;
vscClearElement(btn);
if (svg) {
var cw = vscCreateSvgWrap(doc, svg, "vsc-btn-icon");
if (cw) {
btn.appendChild(cw);
} else {
var cdf = controllerButtonDefs[act];
btn.textContent = (cdf && cdf.label) || "?";
}
} else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, act, 14);
if (wrap) {
btn.appendChild(wrap);
} else {
var cdf = controllerButtonDefs[act];
btn.textContent = (cdf && cdf.label) || "?";
}
} else {
var cdf2 = controllerButtonDefs[act];
btn.textContent = (cdf2 && cdf2.label) || "?";
}
});
updateSubtitleNudgeIndicator(video);
});
}
});
}
initializeWhenReady(document);
});
});
function getKeyBindings(action, what = "value") {
if (!tc.settings.keyBindings) return false;
try {
const binding = tc.settings.keyBindings.find(
(item) => item.action === action
);
if (binding) return binding[what];
if (what === "value") {
if (action === "slower" || action === "faster") return 0.1;
if (action === "rewind" || action === "advance") return 10;
if (action === "reset") return 1.0;
if (action === "fast") return 1.8;
}
return false;
return tc.settings.keyBindings.find((item) => item.action === action)[what];
} catch (e) {
log(`Error in getKeyBindings for ${action} (${what}): ${e.message}`, 2);
return false;
}
}
function setKeyBindings(action, value) {
// Original setKeyBindings
if (!tc.settings.keyBindings) return;
const binding = tc.settings.keyBindings.find(
(item) => item.action === action
);
if (binding) {
binding["value"] = value;
tc.settings.keyBindings.find((item) => item.action === action)["value"] =
value;
}
function createControllerButton(doc, action, label, className) {
var button = doc.createElement("button");
button.dataset.action = action;
var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg;
if (custom) {
var customWrap = vscCreateSvgWrap(doc, custom, "vsc-btn-icon");
if (customWrap) {
button.appendChild(customWrap);
} else {
button.textContent = label || "?";
}
} else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, action, 14);
if (wrap) {
button.appendChild(wrap);
} else {
button.textContent = label || "?";
}
} else {
button.textContent = label || "?";
}
if (className) {
button.className = className;
}
return button;
}
function defineVideoController() {
tc.videoController = function (target, parent) {
tc.videoController = function(target, parent) {
if (target.vsc) return target.vsc;
tc.mediaElements.push(target);
target.vsc = this;
this.video = target;
this.parent = parent || target.parentElement;
this.nudgeIntervalId = null; // ADDED
this.parent = target.parentElement || parent;
this.nudgeAnimationId = null;
this.restoreSpeedTimer = null;
this.pendingRateChange = null;
this.speedRestoreUntil = 0;
this.subtitleNudgeEnabledOverride = null;
this.suppressedRateChangeCount = 0;
this.suppressedRateChangeUntil = 0;
this.visibilityResumeHandler = null;
this.resetToggleArmed = false;
this.resetButtonEl = null;
this.controllerLocation = normalizeControllerLocation(
tc.settings.controllerLocation
);
let storedSpeed; // Original logic for initial speed determination
if (!tc.settings.rememberSpeed) {
storedSpeed = tc.settings.speeds[target.currentSrc];
if (!storedSpeed) storedSpeed = 1.0;
setKeyBindings("reset", getKeyBindings("fast")); // Original call
} else {
storedSpeed =
tc.settings.speeds[target.currentSrc] || tc.settings.lastSpeed;
log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || "none"}`, 4);
let storedSpeed = sanitizeSpeed(resolveTargetSpeed(target), 1.0);
this.targetSpeed = storedSpeed;
this.targetSpeedSourceKey = getVideoSourceKey(target);
this.mediaSourceKey = getVideoSourceKey(target);
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
setKeyBindings("reset", getKeyBindings("fast"));
}
if (tc.settings.forceLastSavedSpeed) storedSpeed = tc.settings.lastSpeed;
log("Explicitly setting playbackRate to: " + storedSpeed, 5);
target.playbackRate = storedSpeed;
this.div = this.initializeControls();
if (Math.abs(target.playbackRate - storedSpeed) > 0.001) {
// MODIFIED: Pass isUserKeyPress = false for initial/automatic speed settings
setSpeed(target, storedSpeed, true, false);
} else {
if (this.speedIndicator)
this.speedIndicator.textContent = storedSpeed.toFixed(2);
if (
!tc.settings.forceLastSavedSpeed &&
tc.settings.lastSpeed !== storedSpeed
) {
tc.settings.lastSpeed = storedSpeed;
}
if (!this.div) {
log("ERROR: Failed to create controller div!", 2);
return;
}
// MODIFIED: mediaEventAction to correctly maintain speed on play/pause/seek/ended
var mediaEventAction = function (event) {
const video = event.target;
if (!video.vsc) return;
log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains("vsc-hidden")}`, 4);
let speedToMaintain;
if (tc.settings.forceLastSavedSpeed) {
speedToMaintain = tc.settings.lastSpeed;
} else if (tc.settings.rememberSpeed) {
speedToMaintain =
tc.settings.speeds[video.currentSrc] || tc.settings.lastSpeed;
} else {
// Not forcing, not remembering per-video. Maintain the current session's tc.settings.lastSpeed.
speedToMaintain = tc.settings.lastSpeed;
var mediaEventAction = function(event) {
if (
event.type === "loadedmetadata" ||
event.type === "loadeddata" ||
event.type === "canplay"
) {
applySourceTransitionPolicy(event.target, false);
}
// The original setKeyBindings("reset", getKeyBindings("fast")) from old mediaEventAction
// was related to the complex 'R' key toggle. That logic is within the main `resetSpeed` function.
// Here, we just ensure the determined `speedToMaintain` is applied if needed.
if (Math.abs(video.playbackRate - speedToMaintain) > 0.001) {
log(
`Media event '${event.type}': video rate ${video.playbackRate.toFixed(2)} vs target ${speedToMaintain.toFixed(2)}. Correcting.`,
4
);
// Corrections from play/pause/seek/ended are not direct user key presses for blink.
setSpeed(video, speedToMaintain, false, false); // isInitialCall=false, isUserKeyPress=false
} else {
log(
`Media event '${event.type}': video rate ${video.playbackRate.toFixed(2)} matches target. No speed change needed.`,
6
);
}
// Manage nudge based on event type
if (event.type === "play") {
if (video.playbackRate !== 1.0) video.vsc.startSubtitleNudge(); // Only start if not 1.0x
} else if (event.type === "pause" || event.type === "ended") {
video.vsc.stopSubtitleNudge();
applySourceTransitionPolicy(event.target, false);
extendSpeedRestoreWindow(event.target);
if (!tc.settings.rememberSpeed && !tc.settings.forceLastSavedSpeed) {
setKeyBindings("reset", getKeyBindings("fast"));
}
var playSpeed = sanitizeSpeed(resolveTargetSpeed(event.target), 1.0);
if (Math.abs(event.target.playbackRate - playSpeed) > 0.01) {
log("Play event: setting playbackRate to: " + playSpeed, 4);
setSpeed(event.target, playSpeed, false, false);
} else if (playSpeed === 1.0 || event.target.paused) {
this.stopSubtitleNudge();
} else {
this.startSubtitleNudge();
}
} else if (event.type === "pause") {
extendSpeedRestoreWindow(event.target);
this.stopSubtitleNudge();
tc.isNudging = false;
} else if (event.type === "seeking") {
extendSpeedRestoreWindow(event.target);
} else if (event.type === "ended") {
this.speedRestoreUntil = 0;
this.stopSubtitleNudge();
tc.isNudging = false;
} else if (event.type === "seeked") {
extendSpeedRestoreWindow(event.target);
var expectedSpeed = sanitizeSpeed(resolveTargetSpeed(event.target), 1.0);
var currentSpeed = event.target.playbackRate;
if (
Math.abs(currentSpeed - expectedSpeed) > 0.01
) {
log(
`Seeked: speed changed from ${expectedSpeed} to ${currentSpeed}, restoring`,
4
);
setSpeed(event.target, expectedSpeed, false, false);
}
if (isUserSeek) {
isUserSeek = false;
}
}
};
target.addEventListener(
"loadedmetadata",
(this.handleLoadedMetadata = mediaEventAction.bind(this))
);
target.addEventListener(
"loadeddata",
(this.handleLoadedData = mediaEventAction.bind(this))
);
target.addEventListener(
"canplay",
(this.handleCanPlay = mediaEventAction.bind(this))
);
target.addEventListener(
"play",
(this.handlePlay = mediaEventAction.bind(this))
@@ -262,6 +1486,10 @@ function defineVideoController() {
"pause",
(this.handlePause = mediaEventAction.bind(this))
);
target.addEventListener(
"seeking",
(this.handleSeeking = mediaEventAction.bind(this))
);
target.addEventListener(
"ended",
(this.handleEnded = mediaEventAction.bind(this))
@@ -272,502 +1500,1026 @@ function defineVideoController() {
);
var srcObserver = new MutationObserver((mutations) => {
// Original srcObserver
mutations.forEach((mutation) => {
if (
mutation.type === "attributes" &&
(mutation.attributeName === "src" ||
mutation.attributeName === "currentSrc")
) {
if (!this.div) return;
this.stopSubtitleNudge();
if (!mutation.target.src && !mutation.target.currentSrc)
this.div.classList.add("vsc-nosource");
else {
this.div.classList.remove("vsc-nosource");
let newSrcSpeed = tc.settings.speeds[mutation.target.currentSrc];
if (!tc.settings.rememberSpeed) {
if (!newSrcSpeed) newSrcSpeed = 1.0;
log("mutation of A/V element", 5);
if (this.div) {
this.stopSubtitleNudge();
if (!mutation.target.src && !mutation.target.currentSrc) {
this.div.classList.add("vsc-nosource");
} else {
newSrcSpeed = newSrcSpeed || tc.settings.lastSpeed;
this.div.classList.remove("vsc-nosource");
applySourceTransitionPolicy(this.video, true);
if (!mutation.target.paused) this.startSubtitleNudge();
}
if (tc.settings.forceLastSavedSpeed)
newSrcSpeed = tc.settings.lastSpeed;
setSpeed(mutation.target, newSrcSpeed, true, false); // isInitialCall=true, isUserKeyPress=false
if (!mutation.target.paused && mutation.target.playbackRate !== 1.0)
this.startSubtitleNudge();
updateSubtitleNudgeIndicator(this.video);
}
}
});
});
this.srcObserver = srcObserver;
srcObserver.observe(target, { attributeFilter: ["src", "currentSrc"] });
if (!target.paused && target.playbackRate !== 1.0)
this.startSubtitleNudge();
};
tc.videoController.prototype.startSubtitleNudge = function () {
// ADDED
if (!location.hostname.includes("youtube.com")) return;
tc.videoController.prototype.remove = function() {
this.stopSubtitleNudge();
if (this.youTubeAutoHideObserver) {
this.youTubeAutoHideObserver.disconnect();
this.youTubeAutoHideObserver = null;
}
if (this.youTubeAutoHideCleanup) {
this.youTubeAutoHideCleanup();
this.youTubeAutoHideCleanup = null;
}
if (this.genericAutoHideCleanup) {
this.genericAutoHideCleanup();
this.genericAutoHideCleanup = null;
}
if (this.div) this.div.remove();
if (this.restoreSpeedTimer) clearTimeout(this.restoreSpeedTimer);
if (this.video) {
this.video.removeEventListener("loadedmetadata", this.handleLoadedMetadata);
this.video.removeEventListener("loadeddata", this.handleLoadedData);
this.video.removeEventListener("canplay", this.handleCanPlay);
this.video.removeEventListener("play", this.handlePlay);
this.video.removeEventListener("pause", this.handlePause);
this.video.removeEventListener("seeking", this.handleSeeking);
this.video.removeEventListener("ended", this.handleEnded);
this.video.removeEventListener("seeked", this.handleSeek);
delete this.video.vsc;
}
if (this.srcObserver) this.srcObserver.disconnect();
let idx = tc.mediaElements.indexOf(this.video);
if (idx != -1) tc.mediaElements.splice(idx, 1);
};
tc.videoController.prototype.startSubtitleNudge = function() {
if (
!tc.settings.enableSubtitleNudge ||
this.nudgeIntervalId !== null ||
!this.video
)
return;
if (this.video.paused || this.video.playbackRate === 1.0) {
this.stopSubtitleNudge();
!isSubtitleNudgeSupported(this.video) ||
!isSubtitleNudgeEnabledForVideo(this.video) ||
this.nudgeAnimationId !== null ||
!this.video ||
this.video.paused ||
this.video.playbackRate === 1.0
) {
return;
}
log(`Nudge: Starting interval: ${tc.settings.subtitleNudgeInterval}ms.`, 5);
this.nudgeIntervalId = setInterval(() => {
if (
!this.video ||
this.video.paused ||
this.video.playbackRate === 1.0 ||
tc.isNudging
) {
// Store the target speed so we can always revert to it
this.targetSpeed = this.video.playbackRate;
const performNudge = () => {
// Check if we should stop
if (!this.video || this.video.paused || this.video.playbackRate === 1.0) {
this.stopSubtitleNudge();
return;
}
const currentRate = this.video.playbackRate;
const nudgeAmount = tc.settings.subtitleNudgeAmount;
// CRITICAL: Don't nudge if tab is hidden - prevents speed drift
if (document.hidden) {
if (!this.visibilityResumeHandler && this.video && this.video.ownerDocument) {
this.visibilityResumeHandler = () => {
if (this.video.ownerDocument.hidden) return;
this.video.ownerDocument.removeEventListener(
"visibilitychange",
this.visibilityResumeHandler,
true
);
this.visibilityResumeHandler = null;
this.startSubtitleNudge();
};
this.video.ownerDocument.addEventListener(
"visibilitychange",
this.visibilityResumeHandler,
true
);
}
this.nudgeAnimationId = null;
return;
}
// Set flag to prevent ratechange listener from interfering
tc.isNudging = true;
this.video.playbackRate = currentRate + nudgeAmount;
requestAnimationFrame(() => {
if (
this.video &&
Math.abs(this.video.playbackRate - (currentRate + nudgeAmount)) <
nudgeAmount * 1.5
) {
this.video.playbackRate = currentRate;
suppressNextNudgeRateChanges(this, 2);
// Cache values to avoid repeated property access
const targetSpeed = this.targetSpeed;
const nudgeAmount = tc.settings.subtitleNudgeAmount;
// Apply nudge from the stored target speed (not current rate)
this.video.playbackRate = targetSpeed + nudgeAmount;
// Revert synchronously after a microtask to ensure it happens immediately
Promise.resolve().then(() => {
if (this.video && targetSpeed) {
this.video.playbackRate = targetSpeed;
}
tc.isNudging = false;
});
}, tc.settings.subtitleNudgeInterval);
// Schedule next nudge
this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval);
};
// Start the first nudge
this.nudgeAnimationId = setTimeout(performNudge, tc.settings.subtitleNudgeInterval);
log(`Nudge: Starting with interval ${tc.settings.subtitleNudgeInterval}ms.`, 5);
};
tc.videoController.prototype.stopSubtitleNudge = function () {
// ADDED
if (this.nudgeIntervalId !== null) {
tc.videoController.prototype.stopSubtitleNudge = function() {
if (this.nudgeAnimationId !== null) {
clearTimeout(this.nudgeAnimationId);
this.nudgeAnimationId = null;
log(`Nudge: Stopping.`, 5);
clearInterval(this.nudgeIntervalId);
this.nudgeIntervalId = null;
}
if (this.visibilityResumeHandler && this.video && this.video.ownerDocument) {
this.video.ownerDocument.removeEventListener(
"visibilitychange",
this.visibilityResumeHandler,
true
);
this.visibilityResumeHandler = null;
}
// Clear the animation state, but PRESERVE targetSpeed so getDesiredSpeed
// doesn't lose the user's intended speed if the site hijacks it.
};
tc.videoController.prototype.remove = function () {
// Original remove
this.stopSubtitleNudge(); // ADDED
if (this.div && this.div.parentNode) this.div.remove();
if (this.video) {
this.video.removeEventListener("play", this.handlePlay);
this.video.removeEventListener("pause", this.handlePause); // ADDED
this.video.removeEventListener("ended", this.handleEnded); // ADDED
this.video.removeEventListener("seeked", this.handleSeek); // Original was "seek"
delete this.video.vsc;
tc.videoController.prototype.performImmediateNudge = function() {
if (
!isSubtitleNudgeSupported(this.video) ||
!isSubtitleNudgeEnabledForVideo(this.video) ||
!this.video ||
this.video.paused ||
this.video.playbackRate === 1.0 ||
document.hidden
) {
return;
}
let idx = tc.mediaElements.indexOf(this.video);
if (idx !== -1) tc.mediaElements.splice(idx, 1);
const targetRate = this.targetSpeed || this.video.playbackRate;
const nudgeAmount = tc.settings.subtitleNudgeAmount;
tc.isNudging = true;
suppressNextNudgeRateChanges(this, 2);
this.video.playbackRate = targetRate + nudgeAmount;
// Revert synchronously via microtask
Promise.resolve().then(() => {
if (this.video) {
this.video.playbackRate = targetRate;
}
tc.isNudging = false;
});
log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5);
};
tc.videoController.prototype.initializeControls = function () {
/* ... Same as your provided ... */
tc.videoController.prototype.setupYouTubeAutoHide = function(wrapper) {
if (!wrapper || !isOnYouTube()) return;
const video = this.video;
const ytPlayer = video.closest(".html5-video-player");
if (!ytPlayer) {
log("YouTube player not found for auto-hide setup", 4);
return;
}
const syncControllerVisibility = () => {
// YouTube adds ytp-autohide class to the player when controls should be hidden
// We mirror this class state to enable CSS-based hiding
// The vsc-hidden class (from V key) takes precedence via CSS specificity
if (ytPlayer.classList.contains("ytp-autohide")) {
wrapper.classList.add("ytp-autohide");
// Immediately end any temporary "vsc-show" state to hide with YouTube
// UNLESS it was forced by a shortcut (vsc-forced-show)
if (!wrapper.classList.contains("vsc-forced-show")) {
wrapper.classList.remove("vsc-show");
if (wrapper.showTimeOut) {
clearTimeout(wrapper.showTimeOut);
wrapper.showTimeOut = undefined;
}
}
log("YouTube controls hidden, hiding controller", 5);
} else {
wrapper.classList.remove("ytp-autohide");
log("YouTube controls visible, showing controller", 5);
}
};
// Initial sync
syncControllerVisibility();
// Observe YouTube player class changes
this.youTubeAutoHideObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "class") {
syncControllerVisibility();
}
});
});
this.youTubeAutoHideObserver.observe(ytPlayer, {
attributes: true,
attributeFilter: ["class"]
});
log("YouTube auto-hide observer setup complete", 4);
// Also reveal on hover/activity independently of YouTube's own controls
// for immediate responsiveness, UNLESS Speeder is actually toggled hidden (vsc-hidden)
const resetTimer = () => {
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
};
const activityEvents = ["mousemove", "mousedown", "touchstart"];
activityEvents.forEach((type) => {
video.addEventListener(type, resetTimer, { passive: true });
wrapper.addEventListener(type, resetTimer, { passive: true });
ytPlayer.addEventListener(type, resetTimer, { passive: true });
});
// Store a cleanup function
this.youTubeAutoHideCleanup = () => {
activityEvents.forEach((type) => {
video.removeEventListener(type, resetTimer);
wrapper.removeEventListener(type, resetTimer);
ytPlayer.removeEventListener(type, resetTimer);
});
};
};
tc.videoController.prototype.setupGenericAutoHide = function(wrapper) {
if (!wrapper) return;
const video = this.video;
let timer = null;
const resetTimer = () => {
wrapper.classList.remove("vsc-idle-hidden");
showController(wrapper, tc.settings.hideWithControlsTimer * 1000);
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
// Only hide if the video is not paused
// (Many players keep controls visible while paused)
// However, the user said "Reveal on every mouse and keyboard input"
// and "auto-hidden after timespan".
// We'll follow the timer strictly.
wrapper.classList.add("vsc-idle-hidden");
log("Generic hide: controller hidden due to inactivity", 5);
}, tc.settings.hideWithControlsTimer * 1000);
};
// Initial show/timer
resetTimer();
// The wrapper covers the player area on most sites due to inject.css styles,
// but we listen on both the video and the wrapper for maximum coverage.
const activityEvents = ["mousemove", "mousedown", "keydown", "touchstart"];
const parentEl = video.parentElement;
activityEvents.forEach((type) => {
video.addEventListener(type, resetTimer, { passive: true });
wrapper.addEventListener(type, resetTimer, { passive: true });
if (parentEl) {
parentEl.addEventListener(type, resetTimer, { passive: true });
}
});
// Also reset timer on play/pause events to ensure sync when player state changes
video.addEventListener("play", resetTimer, { passive: true });
video.addEventListener("pause", resetTimer, { passive: true });
// Store a cleanup function
this.genericAutoHideCleanup = () => {
if (timer) clearTimeout(timer);
activityEvents.forEach((type) => {
video.removeEventListener(type, resetTimer);
wrapper.removeEventListener(type, resetTimer);
if (parentEl) {
parentEl.removeEventListener(type, resetTimer);
}
});
video.removeEventListener("play", resetTimer);
video.removeEventListener("pause", resetTimer);
};
log(`Generic auto-hide setup complete with ${tc.settings.hideWithControlsTimer}s timer`, 4);
};
tc.videoController.prototype.initializeControls = function() {
const doc = this.video.ownerDocument;
const speedForUI = this.video.playbackRate.toFixed(2);
var top = Math.max(this.video.offsetTop, 0) + "px",
left = Math.max(this.video.offsetLeft, 0) + "px";
const speed = this.video.playbackRate.toFixed(2);
var wrapper = doc.createElement("div");
wrapper.classList.add("vsc-controller");
if (!this.video.src && !this.video.currentSrc)
wrapper.classList.add("vsc-nosource");
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
// Use lower z-index for non-YouTube sites to avoid overlapping modals
if (!isOnYouTube()) wrapper.classList.add("vsc-non-youtube");
var shadow = wrapper.attachShadow({ mode: "open" });
shadow.innerHTML = `<style> @import "${chrome.runtime.getURL("shadow.css")}"; </style><div id="controller" style="top:${top}; left:${left}; opacity:${tc.settings.controllerOpacity}"><span data-action="drag" class="draggable">${speedForUI}</span><span id="controls"><button data-action="rewind" class="rw">«</button><button data-action="slower"></button><button data-action="faster">+</button><button data-action="advance" class="rw">»</button><button data-action="display" class="hideButton">×</button></span></div>`;
this.speedIndicator = shadow.querySelector(".draggable");
shadow.querySelector(".draggable").addEventListener(
var shadowStylesheet = doc.createElement("link");
shadowStylesheet.rel = "stylesheet";
shadowStylesheet.href = chrome.runtime.getURL("shadow.css");
shadow.appendChild(shadowStylesheet);
var controller = doc.createElement("div");
controller.id = "controller";
controller.style.opacity = String(tc.settings.controllerOpacity);
this.controllerLocation = applyControllerLocationToElement(
controller,
this.controllerLocation
);
var dragHandle = doc.createElement("span");
dragHandle.dataset.action = "drag";
dragHandle.className = "draggable";
dragHandle.textContent = speed;
var controls = doc.createElement("span");
controls.id = "controls";
var buttonConfig = Array.isArray(tc.settings.controllerButtons)
? tc.settings.controllerButtons
: ["rewind", "slower", "faster", "advance", "display"];
var subtitleNudgeIndicator = null;
buttonConfig.forEach(function(btnId) {
if (btnId === "nudge") {
subtitleNudgeIndicator = doc.createElement("span");
subtitleNudgeIndicator.id = "nudge-indicator";
subtitleNudgeIndicator.setAttribute("role", "button");
subtitleNudgeIndicator.setAttribute("aria-live", "polite");
subtitleNudgeIndicator.setAttribute("tabindex", "0");
controls.appendChild(subtitleNudgeIndicator);
} else {
var def = controllerButtonDefs[btnId];
if (def) {
controls.appendChild(
createControllerButton(doc, btnId, def.label, def.className)
);
}
}
});
var nudgeFlashIndicator = doc.createElement("span");
nudgeFlashIndicator.id = "nudge-flash-indicator";
nudgeFlashIndicator.setAttribute("aria-hidden", "true");
controller.appendChild(dragHandle);
controller.appendChild(controls);
/* Flash sits after #controls so it never inserts space between speed and buttons. */
controller.appendChild(nudgeFlashIndicator);
shadow.appendChild(controller);
this.speedIndicator = dragHandle;
this.subtitleNudgeIndicator = subtitleNudgeIndicator;
this.nudgeFlashIndicator = nudgeFlashIndicator;
this.resetButtonEl =
shadow.querySelector("button[data-action=\"reset\"]") || null;
this.resetToggleArmed = false;
if (subtitleNudgeIndicator) {
updateSubtitleNudgeIndicator(this.video);
}
function blurAfterPointerTap(target, e) {
if (!target || typeof target.blur !== "function") return;
var pt = e.pointerType;
if (pt === "mouse" || pt === "touch" || (!pt && e.detail > 0)) {
requestAnimationFrame(function() {
target.blur();
});
}
}
dragHandle.addEventListener(
"mousedown",
(e) => {
runAction(
e.target.dataset["action"],
getKeyBindings(e.target.dataset["action"], "value"),
e,
this.video
);
var dragAction = dragHandle.dataset.action;
runAction(dragAction, getKeyBindings(dragAction, "value"), e);
e.stopPropagation();
},
true
);
shadow.querySelectorAll("button").forEach((btn) =>
btn.addEventListener(
shadow.querySelectorAll("button").forEach((button) => {
button.addEventListener(
"click",
(e) => {
runAction(
e.target.dataset["action"],
getKeyBindings(e.target.dataset["action"]),
e,
this.video
);
var action = button.dataset.action;
runAction(action, getKeyBindings(action), e);
blurAfterPointerTap(button, e);
e.stopPropagation();
},
true
)
);
shadow
.querySelector("#controller")
.addEventListener("click", (e) => e.stopPropagation(), false);
shadow
.querySelector("#controller")
.addEventListener("mousedown", (e) => e.stopPropagation(), false);
);
});
if (subtitleNudgeIndicator) {
subtitleNudgeIndicator.addEventListener(
"click",
(e) => {
var video = this.video;
if (video) {
var newState = !isSubtitleNudgeEnabledForVideo(video);
setSubtitleNudgeEnabledForVideo(video, newState);
}
blurAfterPointerTap(subtitleNudgeIndicator, e);
e.stopPropagation();
},
true
);
}
controller.addEventListener("click", (e) => e.stopPropagation(), false);
controller.addEventListener("mousedown", (e) => e.stopPropagation(), false);
// Setup auto-hide observers if enabled
if (tc.settings.hideWithControls) {
if (isOnYouTube()) {
this.setupYouTubeAutoHide(wrapper);
} else {
this.setupGenericAutoHide(wrapper);
}
}
var fragment = doc.createDocumentFragment();
fragment.appendChild(wrapper);
const pEl = this.parent || this.video.parentElement;
if (!pEl || !pEl.parentNode) {
const parentEl = this.parent || this.video.parentElement;
log(`Inserting controller: parentEl=${!!parentEl}, parentNode=${!!parentEl?.parentNode}, hostname=${location.hostname}`, 4);
if (!parentEl || !parentEl.parentNode) {
log("No suitable parent found, appending to body", 4);
doc.body.appendChild(fragment);
return wrapper;
}
switch (true) {
case location.hostname == "www.amazon.com":
case location.hostname == "www.reddit.com":
case /hbogo\./.test(location.hostname):
pEl.parentElement.insertBefore(fragment, pEl);
break;
case location.hostname == "www.facebook.com":
let p =
pEl.parentElement.parentElement.parentElement.parentElement
.parentElement.parentElement.parentElement;
if (p && p.firstChild) p.insertBefore(fragment, p.firstChild);
else if (pEl.firstChild) pEl.insertBefore(fragment, pEl.firstChild);
else pEl.appendChild(fragment);
break;
case location.hostname == "tv.apple.com":
const r = pEl.getRootNode();
const s = r && r.querySelector ? r.querySelector(".scrim") : null;
if (s) s.prepend(fragment);
else pEl.insertBefore(fragment, pEl.firstChild);
break;
default:
pEl.insertBefore(fragment, pEl.firstChild);
try {
switch (true) {
case location.hostname == "www.amazon.com":
case location.hostname == "www.reddit.com":
case /hbogo\./.test(location.hostname):
log("Using parentElement.parentElement insertion", 5);
parentEl.parentElement.insertBefore(fragment, parentEl);
break;
case location.hostname == "www.facebook.com":
log("Using Facebook-specific insertion", 5);
let p =
parentEl.parentElement.parentElement.parentElement.parentElement
.parentElement.parentElement.parentElement;
if (p && p.firstChild) p.insertBefore(fragment, p.firstChild);
else parentEl.insertBefore(fragment, parentEl.firstChild);
break;
case location.hostname == "tv.apple.com":
log("Using Apple TV-specific insertion", 5);
const r = parentEl.getRootNode();
const s = r && r.querySelector ? r.querySelector(".scrim") : null;
if (s) s.prepend(fragment);
else parentEl.insertBefore(fragment, parentEl.firstChild);
break;
case location.hostname == "www.youtube.com":
case location.hostname == "m.youtube.com":
case location.hostname == "music.youtube.com":
// YouTube's player DOM has .html5-video-container (video's parent) as a
// low layer with overlay siblings (.ytp-player-content, etc.) on top that
// intercept mouse events. Insert into .html5-video-player (the player
// root) so the controller sits above all overlay layers.
log("Using YouTube-specific insertion", 5);
var ytPlayer = parentEl.closest(".html5-video-player");
if (ytPlayer) {
ytPlayer.insertBefore(fragment, ytPlayer.firstChild);
} else {
parentEl.insertBefore(fragment, parentEl.firstChild);
}
break;
default:
log("Using default insertion method", 5);
parentEl.insertBefore(fragment, parentEl.firstChild);
}
log("Controller successfully inserted into DOM", 4);
} catch (error) {
log(`Error inserting controller: ${error.message}`, 2);
// Fallback to body insertion
doc.body.appendChild(fragment);
}
return wrapper;
};
}
function escapeStringRegExp(str) {
const m = /[|\\{}()[\]^$+*?.]/g;
return str.replace(m, "\\$&");
}
function isBlacklisted() {
/* ... Same as your provided ... */
let blacklisted = false;
const bl = tc.settings.blacklist ? tc.settings.blacklist.split("\n") : [];
bl.forEach((m) => {
if (blacklisted) return;
m = m.replace(regStrip, "");
if (m.length == 0) return;
let rgx;
if (m.startsWith("/") && m.lastIndexOf("/") > 0) {
try {
const ls = m.lastIndexOf("/");
rgx = new RegExp(m.substring(1, ls), m.substring(ls + 1));
} catch (e) {
log(`Invalid regex: ${m}. ${e.message}`, 2);
return;
}
} else rgx = new RegExp(escapeStringRegExp(m));
if (rgx && rgx.test(location.href)) blacklisted = true;
function applySiteRuleOverrides() {
resetSettingsFromSiteRuleBase();
tc.activeSiteRule = null;
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
return false;
}
var currentUrl = location.href;
var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
if (!matchedRule) {
return false;
}
tc.activeSiteRule = matchedRule;
log(`Matched site rule: ${matchedRule.pattern}`, 4);
// Check if extension should be enabled/disabled on this site
if (siteRuleUtils.isSiteRuleDisabled(matchedRule)) {
log(`Extension disabled for site: ${currentUrl}`, 4);
return true;
}
// Override general settings with site-specific overrides
const siteSettings = [
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"controllerLocation",
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginBottom",
"enableSubtitleNudge",
"subtitleNudgeInterval"
];
siteSettings.forEach((key) => {
if (matchedRule[key] !== undefined) {
log(`Overriding ${key} for site: ${matchedRule[key]}`, 4);
tc.settings[key] = matchedRule[key];
}
});
if (blacklisted) log(`Page ${location.href} blacklisted.`, 4);
return blacklisted;
}
var coolDown = false;
function refreshCoolDown() {
/* ... Same as your provided ... */
if (coolDown) clearTimeout(coolDown);
coolDown = setTimeout(() => {
coolDown = false;
}, 1000);
}
function setupListener() {
if (document.vscRateListenerAttached) return;
[
"controllerMarginTop",
"controllerMarginBottom"
].forEach(function(key) {
tc.settings[key] = normalizeControllerMarginPx(tc.settings[key], 0);
});
// MODIFIED: This function NO LONGER calls runAction("blink")
function updateSpeedFromEvent(video) {
if (!video.vsc || !video.vsc.speedIndicator) return;
var speed = Number(video.playbackRate.toFixed(2));
log(`updateSpeedFromEvent: Rate is ${speed}.`, 4);
if (Array.isArray(matchedRule.controllerButtons)) {
log(`Overriding controllerButtons for site`, 4);
tc.settings.controllerButtons = matchedRule.controllerButtons;
}
video.vsc.speedIndicator.textContent = speed.toFixed(2);
tc.settings.speeds[video.currentSrc || "unknown_src"] = speed;
tc.settings.lastSpeed = speed;
chrome.storage.sync.set({ lastSpeed: speed }, () => {
/* ... */
// Override key bindings with site-specific shortcuts
if (Array.isArray(matchedRule.shortcuts) && matchedRule.shortcuts.length > 0) {
var overriddenActions = new Set();
matchedRule.shortcuts.forEach((shortcut) => {
overriddenActions.add(shortcut.action);
});
// Blink is now handled directly in setSpeed for user key presses
// Keep global bindings that aren't overridden, add site-specific ones
tc.settings.keyBindings = tc.settings.keyBindings
.filter((binding) => !overriddenActions.has(binding.action))
.concat(
matchedRule.shortcuts.map((shortcut) =>
normalizeStoredBinding(shortcut)
).filter(Boolean)
);
}
return false;
}
/** Apply current tc.settings controller layout/opacity to every attached controller (after site rules). */
function refreshAllControllerGeometry() {
tc.mediaElements.forEach(function(video) {
if (!video || !video.vsc) return;
applyControllerLocation(video.vsc, tc.settings.controllerLocation);
var controllerEl = getControllerElement(video.vsc);
if (controllerEl) {
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
}
});
}
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
function reapplySiteRulesAndControllerGeometry() {
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
return;
}
refreshAllControllerGeometry();
}
function shouldPreserveDesiredSpeed(video, speed) {
if (!video || !video.vsc) return false;
var desiredSpeed = getDesiredSpeed(video);
if (!isValidSpeed(desiredSpeed) || Math.abs(speed - desiredSpeed) <= 0.01) {
return false;
}
return (
video.paused === true ||
(typeof video.vsc.speedRestoreUntil === "number" &&
video.vsc.speedRestoreUntil > Date.now())
);
}
function setupListener(root) {
root = root || document;
if (root.vscRateListenerAttached) return;
function updateSpeedFromEvent(video, skipResetDisarm) {
if (!video.vsc || !video.vsc.speedIndicator) return;
if (!skipResetDisarm) {
video.vsc.resetToggleArmed = false;
}
var speed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
video.vsc.speedIndicator.textContent = speed.toFixed(2);
video.vsc.targetSpeed = speed;
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
var sourceKey = getVideoSourceKey(video);
if (sourceKey !== "unknown_src") {
tc.settings.speeds[sourceKey] = speed;
}
tc.settings.lastSpeed = speed;
schedulePersistLastSpeed(speed);
if (video.vsc) {
if (speed === 1.0 || video.paused) video.vsc.stopSubtitleNudge();
else video.vsc.startSubtitleNudge();
}
}
document.addEventListener(
root.addEventListener(
"ratechange",
function (event) {
if (tc.isNudging) return; // ADDED: Ignore nudge events
if (coolDown) {
log("Blocked by coolDown", 4);
event.stopImmediatePropagation();
return;
}
function(event) {
if (tc.isNudging) return;
var video = event.target;
if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
return;
const eventOrigin = event.detail && event.detail.origin;
// The 'fromUserInput' flag previously passed to updateSpeedFromEvent is removed.
// Blinking is now directly triggered by `setSpeed` if it's a user key press.
if (shouldIgnoreSuppressedRateChange(video)) return;
if (tc.settings.forceLastSavedSpeed) {
if (eventOrigin === "videoSpeed") {
// This event is from setSpeed's forceLastSavedSpeed path.
// setSpeed would have already handled blinking if it was a user key press.
if (event.detail.speed) {
const detailSpeedNum = Number(event.detail.speed);
if (
!isNaN(detailSpeedNum) &&
Math.abs(video.playbackRate - detailSpeedNum) > 0.001
) {
video.playbackRate = detailSpeedNum;
}
}
updateSpeedFromEvent(video); // Update state
event.stopImmediatePropagation();
if (event.detail && event.detail.origin === "videoSpeed") {
video.playbackRate = event.detail.speed;
updateSpeedFromEvent(video, true);
} else {
if (Math.abs(video.playbackRate - tc.settings.lastSpeed) > 0.001) {
video.playbackRate = tc.settings.lastSpeed;
event.stopImmediatePropagation();
} else {
updateSpeedFromEvent(video); // Just confirming speed
}
video.playbackRate = sanitizeSpeed(tc.settings.lastSpeed, 1.0);
}
event.stopImmediatePropagation();
} else {
// forceLastSavedSpeed is OFF
updateSpeedFromEvent(video); // Update state
// DO NOT stop propagation
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
var desiredSpeed = getDesiredSpeed(video);
var pendingRateChange = takePendingRateChange(video, currentSpeed);
if (pendingRateChange) {
updateSpeedFromEvent(video, true);
return;
}
if (shouldPreserveDesiredSpeed(video, currentSpeed)) {
log(
`Ignoring external rate change to ${currentSpeed.toFixed(4)} while preserving ${desiredSpeed.toFixed(4)}`,
4
);
video.vsc.resetToggleArmed = false;
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
scheduleSpeedRestore(video, desiredSpeed, "pause/play or seek");
return;
}
updateSpeedFromEvent(video);
}
},
true
);
document.vscRateListenerAttached = true;
root.vscRateListenerAttached = true;
}
var vscInitializedDocuments = new Set();
function initializeWhenReady(doc) {
/* ... Same robust init from your code ... */
if (doc.vscInitWhenReadyUniqueFlag1 && doc.readyState !== "loading") return;
doc.vscInitWhenReadyUniqueFlag1 = true;
if (isBlacklisted()) return;
if (doc === window.document && !window.vscPageLoadListenerUniqueFlag1) {
window.addEventListener("load", () => initializeNow(window.document), {
once: true
});
window.vscPageLoadListenerUniqueFlag1 = true;
function clearPendingInitialization(doc) {
if (!doc || !doc.vscPendingInitializeHandler) return;
var handler = doc.vscPendingInitializeHandler;
doc.removeEventListener("DOMContentLoaded", handler);
doc.removeEventListener("readystatechange", handler);
if (doc.defaultView) {
doc.defaultView.removeEventListener("load", handler);
}
if (doc.readyState === "complete") initializeNow(doc);
else if (!doc.vscReadyStateListenerUniqueFlag1) {
doc.addEventListener("readystatechange", function onRSChangeUnique1() {
if (doc.readyState === "complete") {
doc.removeEventListener("readystatechange", onRSChangeUnique1);
initializeNow(doc);
}
});
doc.vscReadyStateListenerUniqueFlag1 = true;
delete doc.vscPendingInitializeHandler;
doc.vscPendingForceReinit = false;
}
function tryInitializeDocument(doc, forceReinit) {
if (!doc) return false;
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) {
return false;
}
initializeNow(doc, forceReinit);
clearPendingInitialization(doc);
return true;
}
function initializeWhenReady(doc, forceReinit = false) {
if (!doc) return;
doc.vscPendingForceReinit = doc.vscPendingForceReinit === true || forceReinit;
if (tryInitializeDocument(doc, doc.vscPendingForceReinit)) {
return;
}
if (doc.vscPendingInitializeHandler) return;
var pendingInitializeHandler = function() {
tryInitializeDocument(doc, doc.vscPendingForceReinit === true);
};
doc.vscPendingInitializeHandler = pendingInitializeHandler;
doc.addEventListener("DOMContentLoaded", pendingInitializeHandler);
doc.addEventListener("readystatechange", pendingInitializeHandler);
if (doc.defaultView) {
doc.defaultView.addEventListener("load", pendingInitializeHandler);
doc.defaultView.setTimeout(pendingInitializeHandler, 0);
} else {
setTimeout(pendingInitializeHandler, 0);
}
}
function inIframe() {
/* ... Same ... */ try {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
function getShadow(parent) {
/* ... Same ... */ let r = [];
function gC(p) {
if (p.firstElementChild) {
var c = p.firstElementChild;
do {
r.push(c);
gC(c);
if (c.shadowRoot) r.push(...getShadow(c.shadowRoot));
c = c.nextElementSibling;
} while (c);
}
function attachKeydownListeners(doc) {
var docs = [doc];
try {
if (inIframe() && window.top.document !== doc) docs.push(window.top.document);
} catch (e) {
}
gC(parent);
return r;
docs.forEach(function(keyDoc) {
if (keyDoc.vscKeydownListenerAttached) return;
keyDoc.addEventListener(
"keydown",
function(event) {
if (
!event.getModifierState ||
event.getModifierState("Alt") ||
event.getModifierState("Control") ||
event.getModifierState("Fn") ||
event.getModifierState("Meta") ||
event.getModifierState("Hyper") ||
event.getModifierState("OS")
) {
return;
}
if (
event.target.nodeName === "INPUT" ||
event.target.nodeName === "TEXTAREA" ||
event.target.isContentEditable
) {
return;
}
if (!tc.mediaElements.length) return;
var item = tc.settings.keyBindings.find(function(binding) {
return matchesKeyBinding(binding, event);
});
if (item) {
runAction(item.action, item.value, event);
// Always prevent default and stop propagation for Speeder shortcuts
// to prevent the website (e.g. YouTube) from reacting to these keys.
event.preventDefault();
event.stopPropagation();
}
},
true
);
keyDoc.vscKeydownListenerAttached = true;
});
}
function initializeNow(doc) {
/* ... Same robust init from your code ... */
if (vscInitializedDocuments.has(doc) || !doc.body) return;
if (!tc.settings.enabled) return;
if (!doc.body.classList.contains("vsc-initialized"))
doc.body.classList.add("vsc-initialized");
if (typeof tc.videoController === "undefined") defineVideoController();
setupListener();
if (
inIframe() &&
doc !== window.top.document &&
!doc.head.querySelector('link[href*="inject.css"]')
) {
var l = doc.createElement("link");
l.href = chrome.runtime.getURL("inject.css");
l.type = "text/css";
l.rel = "stylesheet";
doc.head.appendChild(l);
}
const dFK = new Set([doc]);
try {
if (inIframe() && window.top.document) dFK.add(window.top.document);
} catch (e) {}
dFK.forEach((lD) => {
if (!lD.vscKDLFlagC) {
lD.addEventListener(
"keydown",
function (evt) {
if (!tc.settings.enabled) return;
const tgt = evt.target;
if (
tgt.nodeName === "INPUT" ||
tgt.nodeName === "TEXTAREA" ||
tgt.isContentEditable
)
function attachMutationObserver(root) {
if (root.vscMutationObserverAttached) return;
var pendingMutations = [];
var mutationProcessingScheduled = false;
var observer = new MutationObserver(function(mutations) {
pendingMutations.push(...mutations);
if (mutationProcessingScheduled) return;
mutationProcessingScheduled = true;
requestIdle(
function() {
var mutationsToProcess = pendingMutations.splice(0);
mutationProcessingScheduled = false;
mutationsToProcess.forEach(function(mutation) {
if (mutation.type === "childList") {
mutation.addedNodes.forEach(function(node) {
// Skip text nodes, comments, etc. — only elements can contain media
if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, true);
});
mutation.removedNodes.forEach(function(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, false);
});
return;
if (
evt.getModifierState &&
(evt.getModifierState("Alt") ||
evt.getModifierState("Control") ||
evt.getModifierState("Meta") ||
evt.getModifierState("Fn") ||
evt.getModifierState("Hyper") ||
evt.getModifierState("OS"))
)
return;
if (tc.mediaElements.length === 0 && !lD.querySelector("video,audio"))
return;
var itm = tc.settings.keyBindings.find((k) => k.key === evt.keyCode);
if (itm) {
runAction(itm.action, itm.value, evt);
if (itm.force === "true" || itm.force === true) {
evt.preventDefault();
evt.stopPropagation();
}
}
},
true
);
lD.vscKDLFlagC = true;
}
if (mutation.type !== "attributes") return;
var target = mutation.target;
if (
isMediaElement(target) &&
(mutation.attributeName === "src" ||
mutation.attributeName === "currentSrc")
) {
ensureController(target, target.parentElement || target.parentNode);
return;
}
if (
mutation.attributeName === "aria-hidden" &&
target.attributes["aria-hidden"] &&
target.attributes["aria-hidden"].value === "false"
) {
scanNodeForMedia(
target,
target.parentNode || root.host || mutation.target,
true
);
}
});
},
{ timeout: 1000 }
);
});
if (!doc.vscMOFlagC) {
const o = new MutationObserver((m) => {
if (typeof requestIdleCallback === "function")
requestIdleCallback(() => pM(m), { timeout: 1000 });
else setTimeout(() => pM(m), 200);
});
function pM(ml) {
for (const m of ml) {
if (m.type === "childList") {
m.addedNodes.forEach((n) => {
if (n instanceof Element) cV(n, n.parentNode || m.target, true);
});
m.removedNodes.forEach((n) => {
if (n instanceof Element) cV(n, n.parentNode || m.target, false);
});
} else if (
m.type === "attributes" &&
m.attributeName === "aria-hidden" &&
m.target instanceof Element &&
m.target.getAttribute("aria-hidden") === "false"
) {
const vIT = Array.from(getShadow(m.target)).filter(
(el) => el.tagName === "VIDEO"
);
vIT.forEach((vE) => {
if (!vE.vsc) cV(vE, vE.parentNode || m.target, true);
});
}
}
}
function cV(n, p, a) {
if (!a && !n.isConnected) {
} else if (!a && n.isConnected) return;
if (
n.nodeName === "VIDEO" ||
(n.nodeName === "AUDIO" && tc.settings.audioBoolean)
) {
if (a) {
if (!n.vsc) new tc.videoController(n, p);
} else {
if (n.vsc) n.vsc.remove();
}
} else if (n.children && n.children.length > 0) {
for (let i = 0; i < n.children.length; i++)
cV(n.children[i], n.children[i].parentNode || p, a);
}
}
o.observe(doc, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["aria-hidden"]
});
doc.vscMOFlagC = true;
}
const q = tc.settings.audioBoolean ? "video,audio" : "video";
doc.querySelectorAll(q).forEach((v) => {
if (!v.vsc) new tc.videoController(v, v.parentElement);
observer.observe(root, {
attributeFilter: ["aria-hidden", "src", "currentSrc"],
childList: true,
subtree: true,
attributes: true
});
Array.from(doc.getElementsByTagName("iframe")).forEach((f) => {
root.vscMutationObserverAttached = true;
}
function attachMediaDetectionListeners(root) {
if (root.vscMediaEventListenersAttached) return;
var handleDetectedMedia = function(event) {
var target = event.target;
if (!isMediaElement(target)) return;
ensureController(target, target.parentElement || target.parentNode);
};
[
"loadstart",
"loadeddata",
"loadedmetadata",
"canplay",
"playing",
"play"
].forEach(function(eventName) {
root.addEventListener(eventName, handleDetectedMedia, true);
});
root.vscMediaEventListenersAttached = true;
}
function attachIframeListeners(doc) {
Array.from(doc.getElementsByTagName("iframe")).forEach(function(frame) {
if (!frame.vscLoadListenerAttached) {
frame.addEventListener("load", function() {
try {
if (frame.contentDocument) {
initializeWhenReady(frame.contentDocument, true);
}
} catch (e) {
}
});
frame.vscLoadListenerAttached = true;
}
try {
if (f.contentDocument) initializeWhenReady(f.contentDocument);
} catch (e) {}
if (frame.contentDocument) {
initializeWhenReady(frame.contentDocument);
}
} catch (e) {
}
});
}
function attachNavigationListeners() {
if (window.vscNavigationListenersAttached) return;
var scheduleRescan = function() {
clearTimeout(window.vscNavigationRescanTimer);
window.vscNavigationRescanTimer = setTimeout(function() {
initializeWhenReady(document, true);
}, 300);
};
["pushState", "replaceState"].forEach(function(method) {
if (typeof history[method] !== "function") return;
var original = history[method];
history[method] = function() {
var result = original.apply(this, arguments);
scheduleRescan();
return result;
};
});
window.addEventListener("popstate", scheduleRescan);
window.addEventListener("hashchange", scheduleRescan);
/* YouTube often navigates without a history API call the extension can see first */
if (typeof document !== "undefined" && isOnYouTube()) {
document.addEventListener("yt-navigate-finish", scheduleRescan);
}
window.vscNavigationListenersAttached = true;
}
function initializeNow(doc, forceReinit = false) {
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return;
applySiteRuleOverrides();
if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) {
return;
}
if (!doc.body.classList.contains("vsc-initialized")) {
doc.body.classList.add("vsc-initialized");
}
if (typeof tc.videoController === "undefined") defineVideoController();
attachKeydownListeners(doc);
attachNavigationListeners();
observeRoot(doc);
if (forceReinit) {
log("Force re-initialization requested", 4);
refreshAllControllerGeometry();
}
vscInitializedDocuments.add(doc);
}
// MODIFIED: setSpeed now takes `isInitialCall` and `isUserKeyPress`
function setSpeed(video, speed, isInitialCall = false, isUserKeyPress = false) {
function setSpeed(
video,
speed,
isInitialCall = false,
isUserKeyPress = false,
fromResetSpeedToggle = false
) {
const numericSpeed = Number(speed);
if (isNaN(numericSpeed) || numericSpeed <= 0 || numericSpeed > 16) return;
if (!isValidSpeed(numericSpeed)) {
log(
`Invalid speed rejected: ${speed}, must be between ${MIN_SPEED} and ${MAX_SPEED}`,
2
);
return;
}
if (!video || !video.vsc || !video.vsc.speedIndicator) return;
if (isUserKeyPress && !fromResetSpeedToggle) {
video.vsc.resetToggleArmed = false;
}
log(
`setSpeed: Target ${numericSpeed.toFixed(2)}. Initial: ${isInitialCall}. UserKeyPress: ${isUserKeyPress}`,
4
);
tc.settings.lastSpeed = numericSpeed;
video.vsc.speedIndicator.textContent = numericSpeed.toFixed(2);
// MODIFIED: Directly trigger blink here if it's a user key press and not initial setup
if (isUserKeyPress && !isInitialCall && video.vsc) {
// Ensure controller is available before trying to blink
if (video.vsc.div) {
// Check if controller div exists
log(
`setSpeed: User key press detected, triggering blink for controller.`,
5
);
// Pass the specific video to runAction for blink
runAction("blink", getKeyBindings("blink", "value") || 1000, null, video);
}
// Update the target speed for nudge so it knows what to revert to
video.vsc.targetSpeed = numericSpeed;
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
if (isUserKeyPress && !isInitialCall && video.vsc && video.vsc.div) {
runAction("blink", 1000, null, video); // Pass video to blink
extendSpeedRestoreWindow(video); // Protect against immediate site-driven resets
}
// Try YouTube's native speed API first — keeps subtitles in sync without nudge
var usedNativeSpeed = false;
if (tc.settings.forceLastSavedSpeed) {
video.dispatchEvent(
new CustomEvent("ratechange", {
@@ -775,297 +2527,397 @@ function setSpeed(video, speed, isInitialCall = false, isUserKeyPress = false) {
origin: "videoSpeed",
speed: numericSpeed.toFixed(2),
fromUserInput: isUserKeyPress
} // Pass isUserKeyPress
}
})
);
} else {
if (Math.abs(video.playbackRate - numericSpeed) > 0.001) {
video.playbackRate = numericSpeed;
rememberPendingRateChange(video, numericSpeed);
usedNativeSpeed = tryYouTubeNativeSpeed(video, numericSpeed);
if (!usedNativeSpeed) {
video.playbackRate = numericSpeed;
}
}
}
if (!isInitialCall) refreshCoolDown(); // Original call
if (video.vsc) {
if (numericSpeed === 1.0 || video.paused) video.vsc.stopSubtitleNudge();
else video.vsc.startSubtitleNudge();
if (numericSpeed === 1.0 || video.paused) {
video.vsc.stopSubtitleNudge();
} else if (usedNativeSpeed) {
// YouTube's native API handles subtitle sync — no nudge needed
video.vsc.stopSubtitleNudge();
} else {
video.vsc.startSubtitleNudge();
}
}
}
// MODIFIED: runAction passes `isUserKeyPress=true` to setSpeed for relevant actions
function runAction(action, value, e, specificVideo = null) {
var mediaTagsToProcess = []; // ... (same robust mediaTagsToProcess logic as before) ...
if (specificVideo) mediaTagsToProcess = [specificVideo];
else if (e && e.target) {
const dC = e.target.ownerDocument || document;
let aV = tc.mediaElements.find(
(v) =>
v.ownerDocument === dC &&
(dC.activeElement === v || v.contains(dC.activeElement))
function runAction(action, value, e) {
log("runAction Begin", 5);
var mediaTagsToProcess;
const specificVideo = arguments[3] || null;
var subtitleNudgeToggleValue = null;
if (specificVideo) {
mediaTagsToProcess = [specificVideo];
} else if (e && e.target && e.target.getRootNode) {
// Event-driven action
const docContext = e.target.ownerDocument || document;
mediaTagsToProcess = tc.mediaElements.filter(
(v) => v.ownerDocument === docContext
);
if (aV) mediaTagsToProcess = [aV];
else {
aV = tc.mediaElements.find(
(v) =>
v.ownerDocument === dC &&
v.offsetParent !== null &&
(!v.paused || v.readyState > 0)
const targetController = e.target.getRootNode().host;
if (targetController) {
// If it's a click on a controller, only use that one video
const videoFromController = tc.mediaElements.find(
(v) => v.vsc && v.vsc.div === targetController
);
if (aV) mediaTagsToProcess = [aV];
else {
mediaTagsToProcess = tc.mediaElements.filter(
(v) => v.ownerDocument === dC
);
if (mediaTagsToProcess.length === 0 && tc.mediaElements.length > 0)
mediaTagsToProcess = [tc.mediaElements[0]];
else if (mediaTagsToProcess.length === 0) mediaTagsToProcess = [];
}
if (videoFromController) mediaTagsToProcess = [videoFromController];
}
} else mediaTagsToProcess = tc.mediaElements;
if (
mediaTagsToProcess.length === 0 &&
action !== "display" &&
action !== "blink"
)
return; // Allow blink even if no media for global feedback
} else {
mediaTagsToProcess = tc.mediaElements;
}
if (action === "settings") {
chrome.runtime.sendMessage({ action: "openOptions" });
return;
}
if (mediaTagsToProcess.length === 0 && action !== "display") return;
var targetControllerFromEvent =
e && e.target && e.target.getRootNode && e.target.getRootNode().host
? e.target.getRootNode().host
: null;
const currentActionContext = action;
if (action === "toggleSubtitleNudge" && mediaTagsToProcess.length > 0) {
subtitleNudgeToggleValue = !isSubtitleNudgeEnabledForVideo(
mediaTagsToProcess[0]
);
}
mediaTagsToProcess.forEach(function (v) {
if (!v || !v.vsc || !v.vsc.div || !v.vsc.speedIndicator) return;
var controllerDiv = v.vsc.div;
if (
targetControllerFromEvent &&
targetControllerFromEvent !== controllerDiv &&
action !== "blink"
)
return;
if (action === "blink" && specificVideo && v !== specificVideo) return;
// MODIFIED: `showController` is only called if action implies user directly interacting with speed/video state.
// "display" handles its own visibility. "blink" is for feedback *after* a state change.
const actionsThatShouldShowControllerTemporarily = [
mediaTagsToProcess.forEach(function(v) {
if (!v.vsc) return; // Don't process videos without a controller
var controller = v.vsc.div;
const userDrivenActionsThatShowController = [
"rewind",
"advance",
"faster",
"slower",
"louder",
"softer",
"reset",
"fast",
"move",
"pause",
"muted",
"mark",
"jump",
"drag"
"drag",
"nudge",
"toggleSubtitleNudge",
"display"
];
if (actionsThatShouldShowControllerTemporarily.includes(action)) {
// The original showController is a timed visibility.
// The "blink" action also provides timed visibility.
// By having setSpeed call blink directly for user key presses, this might be redundant here for speed changes.
// However, for seek, pause etc., this existing showController is still relevant.
showController(controllerDiv);
var subtitleNudgeActionBlocked =
(action === "toggleSubtitleNudge" || action === "nudge") &&
!isSubtitleNudgeAvailableForVideo(v);
if (
userDrivenActionsThatShowController.includes(action) &&
action !== "display" &&
!subtitleNudgeActionBlocked
) {
showController(controller, 2000, true);
}
if (v.classList.contains("vsc-cancelled")) return;
const numValue = parseFloat(value);
switch (action) {
case "rewind":
isUserSeek = true;
extendSpeedRestoreWindow(v);
v.currentTime -= numValue;
break;
case "advance":
isUserSeek = true;
extendSpeedRestoreWindow(v);
v.currentTime += numValue;
break;
// MODIFIED: Pass `isUserKeyPress = true`
case "faster":
setSpeed(
v,
Math.min(
(v.playbackRate < 0.07 ? 0.07 : v.playbackRate) + numValue,
16
),
false,
true
);
var fasterStep = numValue;
// Use grid-snapping: always move to the next multiple of fasterStep
// Add a tiny epsilon (1% of step) to jump clear of the current point
var newFasterSpeed = Math.ceil((v.playbackRate + (fasterStep * 0.01)) / fasterStep) * fasterStep;
// Clean up JS floating point math (e.g. 0.30000000000000004 -> 0.30)
newFasterSpeed = Math.round(newFasterSpeed * 1000) / 1000;
setSpeed(v, Math.min(newFasterSpeed, MAX_SPEED), false, true);
break;
case "slower":
setSpeed(v, Math.max(v.playbackRate - numValue, 0.07), false, true);
var slowerStep = numValue;
// Use grid-snapping: always move to the previous multiple of slowerStep
// Subtract a tiny epsilon (1% of step) to jump clear of the current point
var newSlowerSpeed = Math.floor((v.playbackRate - (slowerStep * 0.01)) / slowerStep) * slowerStep;
// Clean up JS floating point math
newSlowerSpeed = Math.round(newSlowerSpeed * 1000) / 1000;
setSpeed(v, Math.max(newSlowerSpeed, MIN_SPEED), false, true);
break;
// MODIFIED: Calls original resetSpeed, passing currentActionContext
case "reset":
resetSpeed(v, 1.0, currentActionContext);
resetSpeed(v, 1.0, false); // Use enhanced resetSpeed
break;
case "fast":
resetSpeed(v, numValue, currentActionContext);
var preferredSpeed = numValue;
// Apply site-specific preferred speed override if available
if (tc.activeSiteRule && typeof tc.activeSiteRule.preferredSpeed === "number") {
preferredSpeed = tc.activeSiteRule.preferredSpeed;
}
resetSpeed(v, preferredSpeed, true);
break;
case "display":
controllerDiv.classList.add("vsc-manual");
controllerDiv.classList.toggle("vsc-hidden");
if (controller.classList.contains("vsc-hidden")) {
controller.classList.remove("vsc-hidden");
showController(controller, 2000, true);
} else {
controller.classList.add("vsc-hidden");
// Clear any show state when explicitly hiding
controller.classList.remove("vsc-show");
controller.classList.remove("vsc-forced-show");
if (controller.showTimeOut) {
clearTimeout(controller.showTimeOut);
controller.showTimeOut = undefined;
}
}
break;
case "blink": // This action is now primarily called by setSpeed itself for user key presses
if (!controllerDiv) return; // Safety check
case "blink":
log(`Blink action: controller hidden=${controller.classList.contains("vsc-hidden")}, timeout=${controller.blinkTimeOut !== undefined}, duration=${numValue}`, 5);
if (
controllerDiv.classList.contains("vsc-hidden") ||
controllerDiv.blinkTimeOut !== undefined
controller.classList.contains("vsc-hidden") ||
controller.blinkTimeOut !== undefined
) {
clearTimeout(controllerDiv.blinkTimeOut);
controllerDiv.classList.remove("vsc-hidden");
controllerDiv.blinkTimeOut = setTimeout(
() => {
// If user manually set controller to be visible (vsc-manual and NOT vsc-hidden), blink should not hide it.
if (
controllerDiv.classList.contains("vsc-manual") &&
!controllerDiv.classList.contains("vsc-hidden")
) {
// Do nothing, respect manual visibility
} else {
// Otherwise, (it was auto-shown by blink, or was already hidden, or user manually hid it)
// blink will ensure it ends up hidden.
controllerDiv.classList.add("vsc-hidden");
}
controllerDiv.blinkTimeOut = undefined;
},
typeof value === "number" && !isNaN(value) ? value : 1000
); // Value for blink duration
var restoreHidden =
controller.restoreHiddenAfterBlink === true ||
controller.classList.contains("vsc-hidden");
if (controller.blinkTimeOut !== undefined) {
clearTimeout(controller.blinkTimeOut);
}
controller.restoreHiddenAfterBlink = restoreHidden;
controller.classList.remove("vsc-hidden");
log(`Controller shown, setting timeout for ${numValue || 1000}ms`, 5);
controller.blinkTimeOut = setTimeout(() => {
if (controller.restoreHiddenAfterBlink === true) {
controller.classList.add("vsc-hidden");
log("Controller auto-hidden after blink timeout", 5);
} else {
log("Controller kept visible", 5);
}
controller.restoreHiddenAfterBlink = false;
controller.blinkTimeOut = undefined;
}, numValue || 1000);
}
break;
case "drag":
if (e) handleDrag(v, e);
break;
case "move":
cycleControllerLocation(v);
break;
case "pause":
pause(v);
break;
case "muted":
muted(v);
break;
case "louder":
volumeUp(v, Number.isFinite(numValue) ? numValue : 0.1);
break;
case "softer":
volumeDown(v, Number.isFinite(numValue) ? numValue : 0.1);
break;
case "mark":
setMark(v);
break;
case "jump":
jumpToMark(v);
break;
default:
log(`Unknown action: ${action}`, 3);
case "toggleSubtitleNudge":
setSubtitleNudgeEnabledForVideo(v, subtitleNudgeToggleValue);
break;
case "nudge":
setSubtitleNudgeEnabledForVideo(
v,
!isSubtitleNudgeEnabledForVideo(v)
);
break;
}
});
log("runAction End", 5);
}
function pause(v) {
/* ... Same as your original ... */ if (v.paused)
v.play().catch((e) => log(`Play err:${e.message}`, 2));
if (v.paused) v.play().catch((e) => log(`Play err:${e.message}`, 2));
else v.pause();
}
// MODIFIED: resetSpeed now calls setSpeed with isUserKeyPress = true
function resetSpeed(v, target, currentActionContext = null) {
log(
`resetSpeed (original): Video current: ${v.playbackRate.toFixed(2)}, Target: ${target.toFixed(2)}, Context: ${currentActionContext}`,
4
);
if (Math.abs(v.playbackRate - target) < 0.01) {
if (v.playbackRate === (getKeyBindings("reset", "value") || 1.0)) {
if (target !== 1.0) {
setSpeed(v, 1.0, false, true);
} else {
setSpeed(v, getKeyBindings("fast", "value"), false, true);
}
function resetSpeed(v, target, isFastKey = false) {
const videoId = getVideoSourceKey(v);
const currentSpeed = v.playbackRate;
if (isFastKey) {
// G key: Toggle between current speed and preferred speed (e.g., 1.8)
const preferredSpeed = target;
const lastToggle = lastToggleSpeed[videoId] || currentSpeed;
if (Math.abs(currentSpeed - preferredSpeed) < 0.01) {
// Currently at preferred speed, toggle to the last speed
setSpeed(v, lastToggle, false, true);
} else {
setSpeed(v, getKeyBindings("reset", "value") || 1.0, false, true);
// Not at preferred speed, save current as toggle speed and go to preferred
lastToggleSpeed[videoId] = currentSpeed;
setSpeed(v, preferredSpeed, false, true);
}
} else {
if (currentActionContext === "reset") {
setKeyBindings("reset", v.playbackRate);
// R key: Toggle between current speed and 1.0
const resetSpeedValue = 1.0;
const lastToggle = lastToggleSpeed[videoId] || currentSpeed;
if (Math.abs(currentSpeed - resetSpeedValue) < 0.01) {
// Currently at 1.0, toggle to the last speed (or 1.8 if no history)
const speedToRestore =
Math.abs(lastToggle - 1.0) < 0.01
? getKeyBindings("fast") || 1.8
: lastToggle;
setSpeed(v, speedToRestore, false, true, true);
} else {
// Not at 1.0, save current as toggle speed and go to 1.0
lastToggleSpeed[videoId] = currentSpeed;
v.vsc.resetToggleArmed = true;
setSpeed(v, resetSpeedValue, false, true, true);
}
setSpeed(v, target, false, true);
}
}
function muted(v) {
/* ... Same as your original ... */ v.muted = !v.muted;
log(`Mute: ${v.muted}`, 5);
var nextMuted = !v.muted;
v.muted = nextMuted;
if (!isOnYouTube()) return;
var ytApi = getYouTubePlayerApi(v);
if (!ytApi) return;
if (nextMuted && typeof ytApi.mute === "function") ytApi.mute();
if (!nextMuted && typeof ytApi.unMute === "function") ytApi.unMute();
}
function getYouTubePlayerApi(video) {
if (!isOnYouTube()) return null;
var playerEl =
(video && video.closest ? video.closest(".html5-video-player") : null) ||
document.getElementById("movie_player") ||
document.querySelector(".html5-video-player");
if (!playerEl) return null;
return playerEl.wrappedJSObject || playerEl;
}
function syncYouTubePlayerVolume(video, volume) {
var ytApi = getYouTubePlayerApi(video);
if (!ytApi || typeof ytApi.setVolume !== "function") return;
ytApi.setVolume(Math.round(volume * 100));
if (volume > 0 && typeof ytApi.unMute === "function") {
ytApi.unMute();
}
}
function setVideoVolume(video, targetVolume) {
var nextVolume = Math.max(0, Math.min(1, Number(targetVolume.toFixed(2))));
video.volume = nextVolume;
if (nextVolume > 0 && video.muted) {
video.muted = false;
}
syncYouTubePlayerVolume(video, nextVolume);
}
function volumeUp(v, value) {
setVideoVolume(v, v.volume + value);
}
function volumeDown(v, value) {
setVideoVolume(v, v.volume - value);
}
function setMark(v) {
/* ... Same as your original ... */ if (!v.vsc) v.vsc = {};
v.vsc.mark = v.currentTime;
log(`Mark: ${v.vsc.mark.toFixed(2)}`, 5);
}
function jumpToMark(v) {
/* ... Same as your original ... */ if (
v.vsc &&
typeof v.vsc.mark === "number"
)
if (v.vsc && typeof v.vsc.mark === "number") {
extendSpeedRestoreWindow(v);
v.currentTime = v.vsc.mark;
else log("No mark.", 4);
}
}
function handleDrag(video, e) {
/* ... Same as your original ... */
if (!video || !video.vsc || !video.vsc.div || !video.vsc.div.shadowRoot)
return;
const ctl = video.vsc.div;
const sCtl = ctl.shadowRoot.querySelector("#controller");
if (!sCtl) return;
var pE = ctl.parentElement;
const c = video.vsc.div;
const sC = convertControllerToManualPosition(video.vsc);
if (!sC) return;
var pE = c.parentElement;
while (
pE &&
pE.parentNode &&
pE.parentNode !== document &&
pE.parentNode.offsetHeight === pE.offsetHeight &&
pE.parentNode.offsetWidth === pE.offsetWidth
)
)
pE = pE.parentNode;
const dB = pE || video.ownerDocument.body;
video.classList.add("vcs-dragging");
sCtl.classList.add("dragging");
sC.classList.add("dragging");
const iXY = [e.clientX, e.clientY],
iCtlXY = [
parseInt(sCtl.style.left, 10) || 0,
parseInt(sCtl.style.top, 10) || 0
];
const sD = (mE) => {
let s = sCtl.style;
s.left = iCtlXY[0] + mE.clientX - iXY[0] + "px";
s.top = iCtlXY[1] + mE.clientY - iXY[1] + "px";
mE.preventDefault();
iCXY = [parseInt(sC.style.left), parseInt(sC.style.top)];
const sD = (e) => {
sC.style.setProperty(
"left",
iCXY[0] + e.clientX - iXY[0] + "px",
"important"
);
sC.style.setProperty(
"top",
iCXY[1] + e.clientY - iXY[1] + "px",
"important"
);
};
const eD = () => {
dB.removeEventListener("mousemove", sD);
dB.removeEventListener("mouseup", eD);
dB.removeEventListener("mouseleave", eD);
sCtl.classList.remove("dragging");
pE.removeEventListener("mousemove", sD);
pE.removeEventListener("mouseup", eD);
pE.removeEventListener("mouseleave", eD);
sC.classList.remove("dragging");
video.classList.remove("vcs-dragging");
};
dB.addEventListener("mousemove", sD);
dB.addEventListener("mouseup", eD);
dB.addEventListener("mouseleave", eD);
pE.addEventListener("mouseup", eD);
pE.addEventListener("mouseleave", eD);
pE.addEventListener("mousemove", sD);
}
var timer = null;
function showController(controller) {
/* ... Same as your original ... */
if (!controller || typeof controller.classList === "undefined") return;
// If controller is manually hidden by user (V pressed to hide), don't auto-show it
// The vsc-manual class is added when 'display' action is triggered.
// vsc-hidden is toggled by 'display' action.
// So, if vsc-manual AND vsc-hidden are present, user explicitly hid it.
if (
controller.classList.contains("vsc-manual") &&
controller.classList.contains("vsc-hidden")
) {
// log("Controller is manually hidden by user, showController will not override.", 5);
return;
}
controller.classList.add("vcs-show"); // For autohide sites like YouTube
// The blink action has its own logic for vsc-hidden and vsc-manual.
// This showController is for the general "make it visible for a bit" from user actions.
// It should not remove vsc-hidden if it was there before adding vsc-show, because blink handles that.
// The original showController just adds vsc-show and sets a timer to remove it.
// The "blink" action in runAction explicitly removes vsc-hidden if present, then adds it back.
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
if (controller && controller.classList)
controller.classList.remove("vsc-show");
timer = false;
}, 2000);
function showController(controller, duration = 2000, forced = false) {
if (!controller || typeof controller.classList === "undefined") return;
var restoreHidden =
controller.restoreHiddenAfterShow === true ||
controller.classList.contains("vsc-hidden");
controller.restoreHiddenAfterShow = restoreHidden;
controller.classList.remove("vsc-hidden");
controller.classList.add("vsc-show");
if (forced) {
controller.classList.add("vsc-forced-show");
}
if (controller.showTimeOut !== undefined) {
clearTimeout(controller.showTimeOut);
}
controller.showTimeOut = setTimeout(function() {
controller.classList.remove("vsc-show");
controller.classList.remove("vsc-forced-show");
if (controller.restoreHiddenAfterShow === true) {
controller.classList.add("vsc-hidden");
}
controller.restoreHiddenAfterShow = false;
controller.showTimeOut = undefined;
}, duration);
}
// Add global listener to handle fullscreen transitions and adjust controller positions
document.addEventListener("fullscreenchange", () => {
tc.mediaElements.forEach((video) => {
if (video.vsc) {
applyControllerLocation(video.vsc, video.vsc.controllerLocation);
}
});
});
+150
View File
@@ -0,0 +1,150 @@
/**
* Lucide static icons via jsDelivr (same registry as lucide.dev).
* ISC License — https://lucide.dev
*/
var LUCIDE_STATIC_VERSION = "1.7.0";
var LUCIDE_CDN_BASE =
"https://cdn.jsdelivr.net/npm/lucide-static@" +
LUCIDE_STATIC_VERSION;
var LUCIDE_TAGS_CACHE_KEY = "lucideTagsCacheV1";
var LUCIDE_TAGS_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 7; /* 7 days */
function lucideIconSvgUrl(slug) {
if (!slug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(slug)) {
return "";
}
return LUCIDE_CDN_BASE + "/icons/" + slug.toLowerCase() + ".svg";
}
function lucideTagsJsonUrl() {
return LUCIDE_CDN_BASE + "/tags.json";
}
/** Collapse whitespace for smaller storage. */
function lucideMinifySvg(s) {
return String(s).replace(/\s+/g, " ").trim();
}
function sanitizeLucideSvg(svgText) {
if (!svgText || typeof svgText !== "string") return null;
var t = String(svgText).replace(/\0/g, "").trim();
if (!/<svg[\s>]/i.test(t)) return null;
var doc = new DOMParser().parseFromString(t, "image/svg+xml");
if (doc.querySelector("parsererror")) return null;
var svg = vscSanitizeSvgTree(doc.querySelector("svg"));
if (!svg) return null;
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
svg.setAttribute("aria-hidden", "true");
return lucideMinifySvg(svg.outerHTML);
}
function fetchLucideSvg(slug) {
var url = lucideIconSvgUrl(slug);
if (!url) {
return Promise.reject(new Error("Invalid icon name"));
}
return fetch(url, { cache: "force-cache" }).then(function (r) {
if (!r.ok) {
throw new Error("Icon not found: " + slug);
}
return r.text();
});
}
function fetchAndCacheLucideTags(chromeLocal, resolve, reject) {
fetch(lucideTagsJsonUrl(), { cache: "force-cache" })
.then(function (r) {
if (!r.ok) throw new Error("tags.json HTTP " + r.status);
return r.json();
})
.then(function (obj) {
var payload = {};
payload[LUCIDE_TAGS_CACHE_KEY] = obj;
payload[LUCIDE_TAGS_CACHE_KEY + "At"] = Date.now();
if (chromeLocal && chromeLocal.set) {
chromeLocal.set(payload, function () {
resolve(obj);
});
} else {
resolve(obj);
}
})
.catch(reject);
}
/** @returns {Promise<Object<string, string[]>>} slug -> tags */
function getLucideTagsMap(chromeLocal, bypassCache) {
return new Promise(function (resolve, reject) {
if (!chromeLocal || !chromeLocal.get) {
fetch(lucideTagsJsonUrl(), { cache: "force-cache" })
.then(function (r) {
return r.json();
})
.then(resolve)
.catch(reject);
return;
}
if (bypassCache) {
fetchAndCacheLucideTags(chromeLocal, resolve, reject);
return;
}
chromeLocal.get(
[LUCIDE_TAGS_CACHE_KEY, LUCIDE_TAGS_CACHE_KEY + "At"],
function (stored) {
if (chrome.runtime.lastError) {
fetchAndCacheLucideTags(chromeLocal, resolve, reject);
return;
}
var data = stored[LUCIDE_TAGS_CACHE_KEY];
var at = stored[LUCIDE_TAGS_CACHE_KEY + "At"];
if (
data &&
typeof data === "object" &&
at &&
Date.now() - at < LUCIDE_TAGS_MAX_AGE_MS
) {
resolve(data);
return;
}
fetchAndCacheLucideTags(chromeLocal, resolve, reject);
}
);
});
}
/**
* @param {Object<string,string[]>} tagsMap
* @param {string} query
* @param {number} limit
* @returns {string[]} slugs
*/
function searchLucideSlugs(tagsMap, query, limit) {
var lim = limit != null ? limit : 60;
var q = String(query || "")
.toLowerCase()
.trim();
if (!tagsMap || !q) return [];
var matches = [];
for (var slug in tagsMap) {
if (!Object.prototype.hasOwnProperty.call(tagsMap, slug)) continue;
var hay =
slug +
" " +
(Array.isArray(tagsMap[slug]) ? tagsMap[slug].join(" ") : "");
if (hay.toLowerCase().indexOf(q) === -1) continue;
matches.push(slug);
}
matches.sort(function (a, b) {
var al = a.toLowerCase();
var bl = b.toLowerCase();
var ap = al.indexOf(q) === 0 ? 0 : 1;
var bp = bl.indexOf(q) === 0 ? 0 : 1;
if (ap !== bp) return ap - bp;
return al.localeCompare(bl);
});
return matches.slice(0, lim);
}
+39 -11
View File
@@ -1,13 +1,18 @@
{
"name": "Video Speed Controller",
"short_name": "videospeed",
"version": "1.1.2",
"name": "Speeder",
"short_name": "Speeder",
"version": "5.2.7.0",
"manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts",
"homepage_url": "https://github.com/SoPat712/videospeed",
"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",
"browser_specific_settings": {
"gecko": {
"id": "{ed860648-f54f-4dc9-9a0d-501aec4313f5}"
"id": "{ed860648-f54f-4dc9-9a0d-501aec4313f5}",
"data_collection_permissions": {
"required": [
"none"
]
}
}
},
"icons": {
@@ -15,7 +20,15 @@
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["storage"],
"background": {
"scripts": [
"background.js"
]
},
"permissions": [
"storage",
"https://cdn.jsdelivr.net/*"
],
"options_ui": {
"page": "options.html",
"open_in_tab": true
@@ -31,16 +44,31 @@
"content_scripts": [
{
"all_frames": true,
"matches": ["http://*/*", "https://*/*", "file:///*"],
"matches": [
"http://*/*",
"https://*/*",
"file:///*"
],
"match_about_blank": true,
"exclude_matches": [
"https://plus.google.com/hangouts/*",
"https://hangouts.google.com/*",
"https://meet.google.com/*"
],
"css": ["inject.css"],
"js": ["inject.js"]
"css": [
"inject.css"
],
"js": [
"shared/controller-utils.js",
"shared/key-bindings.js",
"shared/site-rules.js",
"ui-icons.js",
"inject.js"
]
}
],
"web_accessible_resources": ["inject.css", "shadow.css"]
"web_accessible_resources": [
"inject.css",
"shadow.css"
]
}
+1269 -80
View File
@@ -1,112 +1,1301 @@
:root {
--bg: #f4f5f7;
--panel: #ffffff;
--panel-subtle: #fafbfc;
--border: #e2e5e9;
--border-strong: #d4d9e0;
--text: #17191c;
--muted: #626b76;
--accent: #111827;
--switch-track-off: #c1cad6;
--switch-track-off-border: #aeb8c5;
--switch-track-on: #111827;
--switch-track-on-border: #111827;
--switch-thumb-off: #ffffff;
--switch-thumb-on: #ffffff;
--toggle-open-fg: #111827;
--toggle-open-bg: #eef2f6;
--toggle-open-border: #c5ccd5;
--toggle-open-hover-bg: #e4eaf1;
--toggle-open-hover-border: #b5c0cc;
--danger: #b42318;
}
* {
box-sizing: border-box;
}
html {
/* Avoid coupling to the browser viewport: embedded options (e.g. Add-ons
* Manager iframe) must size to content, not 100vh, or a large empty band
* appears below the page. */
height: auto;
min-height: 0;
}
body {
margin: 0;
padding-left: 15px;
padding-top: 53px;
font-family: sans-serif;
font-size: 12px;
color: rgb(48, 57, 66);
min-height: 0;
padding: 24px 16px 40px;
background: var(--bg);
color: var(--text);
font: 14px/1.45 "Avenir Next", "SF Pro Text", "Segoe UI", sans-serif;
}
.page-shell {
width: min(880px, 100%);
margin: 0 auto;
}
.page-header {
margin-bottom: 10px;
}
.title-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title-block {
max-width: 560px;
}
h1,
h2,
h3 {
font-weight: normal;
line-height: 1;
user-select: none;
cursor: default;
}
h1 {
font-size: 1.5em;
margin: 21px 0 13px;
}
h3 {
font-size: 1.2em;
margin-bottom: 0.8em;
color: black;
}
p {
margin: 0.65em 0;
h3,
h4 {
margin: 0;
font-family: "Avenir Next", "SF Pro Display", "Segoe UI", sans-serif;
font-weight: 600;
letter-spacing: -0.01em;
}
header {
position: fixed;
top: 0;
left: 15px;
right: 0;
border-bottom: 1px solid #eee;
background: linear-gradient(white, white 40%, rgba(255, 255, 255, 0.92));
h1 {
font-size: 28px;
line-height: 1.1;
}
header,
section {
min-width: 600px;
max-width: 738px;
h3 {
font-size: 16px;
line-height: 1.25;
}
section {
padding-left: 18px;
margin-top: 8px;
margin-bottom: 24px;
h4 {
font-size: 15px;
line-height: 1.3;
}
section h3 {
margin-left: -18px;
.page-subtitle,
.section-intro {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}
.version {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 9px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel);
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
.settings-stack {
display: grid;
gap: 10px;
}
.settings-card {
padding: 18px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel);
}
.control-bars-group {
padding: 20px;
}
.control-bars-inner {
display: grid;
gap: 10px;
}
.settings-card-nested {
background: var(--panel-subtle);
border-radius: 10px;
}
.section-heading-major {
margin-bottom: 14px;
}
.section-heading-major h2 {
margin: 0 0 6px;
font-size: 18px;
font-weight: 650;
letter-spacing: -0.02em;
}
.section-heading-major .section-intro {
margin-top: 0;
}
.section-heading {
margin-bottom: 10px;
}
.defaults-divider {
height: 1px;
background: var(--border);
margin: 14px 0 10px;
}
.defaults-sub-heading {
font-size: 13px;
font-weight: 600;
color: var(--muted);
margin-bottom: 8px;
}
p {
margin: 0.75em 0;
}
a,
a:visited {
color: var(--text);
text-decoration-color: #c6ccd5;
text-underline-offset: 0.14em;
}
a:hover,
a:focus {
color: #000;
}
code {
padding: 0.08em 0.38em;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-subtle);
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 0.92em;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
appearance: none;
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--panel);
color: var(--text);
font-weight: 500;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease;
}
button:hover {
background: #f8f9fb;
border-color: #c5ccd5;
}
button:active {
background: #f1f3f5;
}
button:focus-visible,
input[type="checkbox"]:focus-visible,
input[type="text"]:focus,
select:focus,
textarea:focus {
outline: 2px solid rgba(17, 24, 39, 0.14);
outline-offset: 2px;
}
#save {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
#save:hover {
background: #1f2937;
border-color: #1f2937;
}
input[type="text"],
select,
textarea {
width: 100%;
min-height: 36px;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--panel);
color: var(--text);
}
input[type="text"]:focus,
select:focus,
textarea:focus {
border-color: #9ca3af;
}
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
position: relative;
margin: 0 1px 0 0;
padding: 0 10px;
min-width: 4em;
min-height: 2em;
background-image: linear-gradient(#ededed, #ededed 38%, #dedede);
border: 1px solid rgba(0, 0, 0, 0.25);
border-radius: 2px;
outline: none;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08),
inset 0 1px 2px rgba(255, 255, 255, 0.75);
color: #444;
text-shadow: 0 1px 0 rgb(240, 240, 240);
font: inherit;
user-select: none;
width: 46px;
min-width: 46px;
height: 28px;
margin: 0;
border: 1px solid var(--switch-track-off-border);
border-radius: 999px;
background: var(--switch-track-off);
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease,
box-shadow 120ms ease;
flex-shrink: 0;
}
input[type="text"] {
width: 75px;
text-align: center;
input[type="checkbox"]::before {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--switch-thumb-off);
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.18),
inset 0 0 0 1px rgba(17, 24, 39, 0.08);
transition: transform 120ms ease, background-color 120ms ease;
}
.row {
margin: 5px 0px;
input[type="checkbox"]:checked {
background: var(--switch-track-on);
border-color: var(--switch-track-on-border);
}
input[type="checkbox"]:checked::before {
transform: translateX(18px);
background: var(--switch-thumb-on);
}
input[type="checkbox"]:disabled {
cursor: default;
opacity: 0.7;
}
label {
display: inline-block;
width: 170px;
vertical-align: top;
display: block;
}
label em {
display: block;
margin-top: 4px;
color: var(--muted);
font-style: normal;
}
.shortcuts-grid {
display: flex;
flex-direction: column;
}
.shortcut-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px 120px;
gap: 12px;
align-items: center;
padding: 10px 0;
border-top: 1px solid var(--border);
}
.shortcuts-grid .shortcut-row:first-child {
padding-top: 0;
border-top: 0;
}
.shortcut-row.customs {
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
}
.shortcut-label {
color: var(--text);
font-weight: 500;
}
.shortcut-label em {
display: block;
margin-top: 4px;
color: var(--muted);
font-style: normal;
font-weight: 400;
}
.customKey,
.customValue {
text-align: center;
}
/* Chevron: native menu indicator is often missing with themed controls */
#addShortcutSelector,
.site-add-shortcut-selector {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: var(--panel);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%234b5563' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px 16px;
padding-right: 38px;
cursor: pointer;
}
#addShortcutSelector:disabled,
.site-add-shortcut-selector:disabled {
cursor: not-allowed;
opacity: 0.72;
}
#addShortcutSelector {
width: min(220px, 100%);
margin-top: 12px;
}
.removeParent,
.toggle-site-rule {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
font-size: 18px;
line-height: 1;
}
.removeParent {
color: var(--danger);
font-weight: 500;
}
.toggle-site-rule {
font-weight: 400;
color: var(--muted);
}
.toggle-site-rule:hover {
color: var(--toggle-open-fg);
background: var(--toggle-open-hover-bg);
border-color: var(--toggle-open-hover-border);
}
.toggle-site-rule .site-rule-toggle-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
pointer-events: none;
}
.toggle-site-rule .site-rule-toggle-icon svg {
width: 18px;
height: 18px;
}
.site-rule:not(.collapsed) .toggle-site-rule {
color: var(--toggle-open-fg);
background: var(--toggle-open-bg);
border-color: var(--toggle-open-border);
}
.site-rule:not(.collapsed) .toggle-site-rule:hover {
color: var(--toggle-open-fg);
background: var(--toggle-open-hover-bg);
border-color: var(--toggle-open-hover-border);
}
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) 160px;
gap: 16px;
align-items: start;
padding: 10px 0;
border-top: 1px solid var(--border);
}
.row input[type="text"],
.row select {
justify-self: end;
}
.row.row-checkbox {
grid-template-columns: minmax(0, 1fr) auto;
}
.row.row-checkbox input[type="checkbox"] {
justify-self: end;
}
.settings-card .row:first-of-type {
padding-top: 0;
border-top: 0;
}
.row.row-controller-margin {
grid-template-columns: minmax(0, 1fr) minmax(0, 260px);
}
.controller-margin-inputs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 116px));
gap: 8px;
width: max-content;
justify-self: end;
}
.margin-pad-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 116px;
}
.margin-pad-mini {
font-size: 10px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.controller-margin-inputs input[type="text"] {
width: 100%;
min-width: 116px;
box-sizing: border-box;
text-align: right;
}
.site-rule-option.site-rule-margin-option {
grid-template-columns: minmax(0, 1fr) minmax(0, 260px);
}
.site-rule-override-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.site-rule-content > .site-rule-override-section:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.site-override-lead {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: start;
font-weight: 600;
margin-bottom: 8px;
cursor: pointer;
width: 100%;
}
.site-override-lead input[type="checkbox"] {
justify-self: end;
}
.site-override-lead span {
margin: 0;
font-weight: 600;
}
.site-override-lead span em {
font-weight: 400;
}
.site-rule-override-section .site-override-fields,
.site-rule-override-section .site-placement-container,
.site-rule-override-section .site-visibility-container,
.site-rule-override-section .site-autohide-container,
.site-rule-override-section .site-playback-container,
.site-rule-override-section .site-opacity-container,
.site-rule-override-section .site-subtitleNudge-container,
.site-controlbar-container,
.site-popup-controlbar-container,
.site-shortcuts-container {
padding-left: 4px;
}
.site-override-disabled {
opacity: 0.48;
pointer-events: none;
user-select: none;
}
.cb-editor {
display: flex;
flex-direction: column;
gap: 16px;
}
.cb-editor-disabled {
opacity: 0.4;
pointer-events: none;
user-select: none;
}
.cb-zone-label {
font-size: 11px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.cb-dropzone {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 52px;
padding: 10px;
border: 2px dashed var(--border);
border-radius: 10px;
background: var(--panel-subtle);
transition: border-color 150ms ease, background 150ms ease;
}
.cb-dropzone.cb-over {
border-color: var(--accent);
background: rgba(17, 24, 39, 0.03);
}
.cb-active-zone:empty::after {
content: "Drag buttons here";
display: flex;
align-items: center;
width: 100%;
justify-content: center;
color: var(--muted);
font-size: 13px;
font-style: italic;
}
.cb-available-zone:empty::after {
content: "All buttons active";
display: flex;
align-items: center;
width: 100%;
justify-content: center;
color: var(--muted);
font-size: 13px;
font-style: italic;
}
.cb-block {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px 7px 8px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--panel);
cursor: grab;
user-select: none;
transition: box-shadow 150ms ease, opacity 150ms ease;
}
.cb-block:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.cb-block:active {
cursor: grabbing;
}
.cb-block.cb-dragging {
opacity: 0.35;
}
.cb-grip {
width: 6px;
min-width: 6px;
height: 14px;
background-image: radial-gradient(circle, currentColor 1px, transparent 1px);
background-size: 3px 3px;
opacity: 0.35;
}
.cb-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
background: var(--panel-subtle);
border: 1px solid var(--border);
font-size: 12px;
line-height: 1;
color: var(--text);
}
.cb-icon svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.cb-icon.cb-icon-nudge-pair {
width: auto;
min-width: 0;
padding: 0 4px;
gap: 4px;
background: transparent;
border: none;
}
.cb-nudge-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
flex-shrink: 0;
color: #fff;
}
.cb-nudge-chip[data-nudge-state="on"] {
background: #4b9135;
border: 1px solid #6ec754;
}
.cb-nudge-chip[data-nudge-state="off"] {
background: #943e3e;
border: 1px solid #c06060;
}
.cb-nudge-chip .vsc-btn-icon svg,
.cb-nudge-chip svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.cb-nudge-sep {
font-size: 11px;
font-weight: 600;
opacity: 0.45;
color: var(--text);
flex-shrink: 0;
}
.row-lucide-pair select {
justify-self: end;
}
.row-lucide-search-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 12px 0;
}
.row-lucide-search-row .lucide-search-label {
font-weight: 600;
font-size: 13px;
color: var(--text);
}
.lucide-search-field {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
max-width: 100%;
min-height: 44px;
padding: 0 14px 0 12px;
border: 1px solid var(--border-strong);
border-radius: 12px;
background: var(--panel);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.lucide-search-field:focus-within {
border-color: #9ca3af;
box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.08);
}
.lucide-search-icon {
display: flex;
color: var(--muted);
flex-shrink: 0;
}
.lucide-search-input {
flex: 1;
min-width: 0;
min-height: 40px;
padding: 8px 0;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
font-size: 14px;
}
.lucide-search-input::placeholder {
color: var(--muted);
opacity: 0.85;
}
.lucide-search-input:focus {
outline: none;
}
.lucide-icon-results {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 220px;
overflow-y: auto;
padding: 12px 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
button.lucide-result-tile {
width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: var(--panel-subtle);
border: 1px solid var(--border-strong);
cursor: pointer;
}
button.lucide-result-tile:hover {
background: var(--panel);
border-color: #9ca3af;
}
button.lucide-result-tile .lucide-result-thumb {
width: 22px;
height: 22px;
object-fit: contain;
pointer-events: none;
}
button.lucide-result-tile.lucide-picked {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(17, 24, 39, 0.12);
background: var(--panel);
}
.lucide-icon-status {
margin: 8px 0 0;
font-size: 12px;
color: var(--muted);
min-height: 1.2em;
}
.lucide-icon-preview-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 16px;
align-items: start;
margin-top: 14px;
}
.lucide-icon-preview {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--panel-subtle);
color: var(--text);
}
.lucide-icon-preview svg {
width: 36px;
height: 36px;
}
.lucide-icon-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.lucide-icon-actions .lucide-apply {
background: #ffffff !important;
color: #111827 !important;
border: 1px solid var(--border-strong) !important;
font-weight: 600;
}
.lucide-icon-actions .lucide-apply:hover {
background: #f3f4f6 !important;
border-color: #9ca3af !important;
}
.lucide-icon-actions .secondary {
background: var(--panel-subtle);
color: var(--text);
border-color: var(--border-strong);
}
.cb-label {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
#siteRulesContainer {
display: grid;
gap: 12px;
margin-bottom: 12px;
}
.site-rule {
padding: 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel-subtle);
}
.site-rule-header {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.site-pattern {
min-width: 0;
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
}
.site-rule-body {
margin-top: 12px;
}
.site-rule-content.disabled-rule {
opacity: 0.55;
pointer-events: none;
}
.site-rule-option {
display: grid;
grid-template-columns: minmax(0, 1fr) 160px;
gap: 16px;
align-items: start;
padding: 8px 0;
border-top: 1px solid var(--border);
}
.site-rule-option-checkbox {
grid-template-columns: minmax(0, 1fr) auto;
}
.site-rule-option-checkbox > input[type="checkbox"] {
justify-self: end;
}
.site-rule-option > input[type="text"],
.site-rule-option > select {
justify-self: end;
text-align: right;
}
.site-rule-option.site-rule-margin-option .controller-margin-inputs {
justify-self: end;
}
.site-rule-body > .site-rule-option:first-child,
.site-rule-content > .site-rule-option:first-child {
padding-top: 0;
border-top: 0;
}
.site-rule-option > label:not(.site-rule-split-label) {
display: block;
margin: 0;
}
.site-rule-split-label {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: flex-start;
width: 100%;
margin: 0;
cursor: pointer;
font-weight: 500;
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;
}
.site-rule-option-checkbox > .site-rule-split-label {
grid-column: 1 / -1;
}
.site-rule-controlbar,
.site-rule-shortcuts {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.site-rule-controlbar > label.site-override-lead,
.site-rule-shortcuts > label.site-override-lead {
margin: 0;
}
.site-controlbar-container,
.site-popup-controlbar-container {
margin-top: 12px;
}
.site-shortcuts-container {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.site-shortcuts-container .shortcut-row {
grid-template-columns: minmax(0, 1fr) 110px 110px minmax(0, 1fr) 38px;
padding: 8px 0;
border-top: 1px solid var(--border);
}
.site-shortcuts-rows .shortcut-row:first-child {
padding-top: 0;
border-top: 0;
}
.site-add-shortcut-selector {
width: min(220px, 100%);
align-self: flex-start;
margin-top: 0;
}
.force-label {
display: flex;
align-items: center;
gap: 10px;
width: auto;
margin: 0;
color: var(--muted);
font-size: 12px;
}
.force-label input {
margin-top: 0;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
#status {
color: #9d9d9d;
display: inline-block;
margin-left: 50px;
min-height: 1.3em;
margin-top: 10px;
color: var(--muted);
font-weight: 500;
}
#faq {
margin-top: 2em;
}
select {
width: 170px;
}
.customForce {
#status:empty {
display: none;
width: 250px;
}
.customKey {
color: transparent;
text-shadow: 0 0 0 #000000;
.support-cta {
margin-top: 14px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel-subtle);
}
.support-cta-text {
margin: 0 0 12px;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.support-cta-links {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.support-cta-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
text-decoration: none;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.support-cta-link:hover {
background: var(--toggle-open-bg);
border-color: var(--toggle-open-border);
}
.support-cta-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.support-cta-link svg {
width: 24px;
height: 24px;
display: block;
}
.support-cta-link--kofi {
background: #fff4ef;
}
.support-cta-link--kofi:hover {
background: #ffe8de;
}
.support-cta-kofi-img {
display: block;
height: 26px;
width: auto;
}
.support-cta-link--bmc {
color: #0d0c22;
background: #ffdd00;
}
.support-cta-link--bmc:hover {
background: #f7d500;
}
.support-cta-link--bmc svg {
width: 22px;
height: 22px;
display: block;
}
@media (max-width: 720px) {
.lucide-icon-preview-row {
grid-template-columns: 1fr;
}
.shortcut-row,
.shortcut-row.customs,
.row,
.row.row-checkbox,
.site-rule-option,
.site-shortcuts-container .shortcut-row {
grid-template-columns: 1fr;
}
.row input[type="text"],
.row select {
justify-self: stretch;
}
.site-rule-option > input[type="text"],
.site-rule-option > select {
justify-self: stretch;
}
.site-override-lead {
grid-template-columns: minmax(0, 1fr) auto;
}
.action-row button,
#addShortcutSelector,
.site-add-shortcut-selector {
width: 100%;
}
.site-rule-header {
grid-template-columns: 36px minmax(0, 1fr);
}
.remove-site-rule {
grid-column: 1 / -1;
}
}
@media (max-width: 520px) {
body {
padding: 16px 12px 28px;
}
h1 {
font-size: 24px;
}
.settings-card {
padding: 16px;
}
.control-bars-group {
padding: 16px;
}
.site-rule-header {
grid-template-columns: 1fr;
}
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111315;
--panel: #171a1d;
--panel-subtle: #1b1f23;
--border: #2b3138;
--border-strong: #3a414a;
--text: #f2f4f6;
--muted: #a0a8b2;
--accent: #f2f4f6;
--switch-track-off: #374151;
--switch-track-off-border: #4b5563;
--switch-track-on: #aab7c6;
--switch-track-on-border: #aab7c6;
--switch-thumb-off: #f8fafc;
--switch-thumb-on: #111315;
--toggle-open-fg: #f2f4f6;
--toggle-open-bg: #2b3138;
--toggle-open-border: #4b5563;
--toggle-open-hover-bg: #374151;
--toggle-open-hover-border: #64748b;
--danger: #ff8a80;
}
body {
color-scheme: dark;
}
a,
a:visited {
color: #f2f4f6;
text-decoration-color: #4b5563;
}
#save {
background: #f2f4f6;
border-color: #f2f4f6;
color: #111315;
}
#save:hover {
background: #dfe3e8;
border-color: #dfe3e8;
}
.cb-dropzone.cb-over {
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 {
border-color: #6b7280;
}
.lucide-search-field:focus-within {
border-color: #6b7280;
box-shadow: 0 0 0 3px rgba(242, 244, 246, 0.12);
}
.lucide-icon-actions .lucide-apply {
background: #ffffff !important;
color: #111315 !important;
border-color: #e5e7eb !important;
}
.lucide-icon-actions .lucide-apply:hover {
background: #f3f4f6 !important;
border-color: #d1d5db !important;
}
button.lucide-result-tile .lucide-result-thumb {
filter: brightness(0) invert(1);
opacity: 0.92;
}
.support-cta-link--kofi {
background: #2c241f;
}
.support-cta-link--kofi:hover {
background: #3a312a;
}
.support-cta-link--bmc {
color: #ffdd00;
background: #2a2618;
}
.support-cta-link--bmc:hover {
background: #3d3510;
}
}
+931 -225
View File
@@ -1,244 +1,950 @@
<!doctype html>
<html>
<head>
<title>Video Speed Controller: Options</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Speeder Settings</title>
<link rel="stylesheet" href="options.css" />
<script src="shared/controller-utils.js"></script>
<script src="shared/key-bindings.js"></script>
<script src="shared/popup-controls.js"></script>
<script src="ui-icons.js"></script>
<script src="lucide-client.js"></script>
<script src="options.js"></script>
<script src="shared/import-export.js"></script>
<script src="importExport.js"></script>
</head>
<body>
<header>
<h1>Video Speed Controller</h1>
</header>
<div class="page-shell">
<header class="page-header">
<div class="title-row">
<div class="title-block">
<h1>Speeder settings</h1>
<p class="page-subtitle">Shortcuts, defaults, and site rules.</p>
</div>
<div class="version">v<span id="app-version"></span></div>
</div>
<div class="support-cta" role="region" aria-label="Support Speeder">
<p class="support-cta-text">
If Speeder has been useful, please consider supporting its development!
</p>
<div class="support-cta-links">
<a
class="support-cta-link support-cta-link--kofi"
href="https://ko-fi.com/joshpatra"
target="_blank"
rel="noopener noreferrer"
aria-label="Support on Ko-fi (opens in new tab)"
>
<img
class="support-cta-kofi-img"
src="images/kofi_symbol.svg"
width="241"
height="194"
alt=""
decoding="async"
/>
</a>
<a
class="support-cta-link support-cta-link--github"
href="https://github.com/sponsors/SoPat712"
target="_blank"
rel="noopener noreferrer"
aria-label="Sponsor on GitHub (opens in new tab)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>
</a>
<a
class="support-cta-link support-cta-link--bmc"
href="https://buymeacoffee.com/treeman183"
target="_blank"
rel="noopener noreferrer"
aria-label="Support on Buy Me a Coffee (opens in new tab)"
>
<svg
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"
/>
</svg>
</a>
</div>
</div>
</header>
<section id="customs">
<h3>Shortcuts</h3>
<div class="row customs" id="display">
<select class="customDo">
<option value="display">Show/hide controller</option>
</select>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input class="customValue" type="text" placeholder="value (0.10)" />
<select class="customForce">
<option value="false">Do not disable website key bindings</option>
<option value="true">Disable website key bindings</option>
</select>
</div>
<div class="row customs" id="slower">
<select class="customDo">
<option value="slower">Decrease speed</option>
</select>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input class="customValue" type="text" placeholder="value (0.10)" />
<select class="customForce">
<option value="false">Do not disable website key bindings</option>
<option value="true">Disable website key bindings</option>
</select>
</div>
<div class="row customs" id="faster">
<select class="customDo">
<option value="faster">Increase speed</option>
</select>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input class="customValue" type="text" placeholder="value (0.10)" />
<select class="customForce">
<option value="false">Do not disable website key bindings</option>
<option value="true">Disable website key bindings</option>
</select>
</div>
<div class="row customs" id="rewind">
<select class="customDo">
<option value="rewind">Rewind</option>
</select>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input class="customValue" type="text" placeholder="value (10)" />
<select class="customForce">
<option value="false">Do not disable website key bindings</option>
<option value="true">Disable website key bindings</option>
</select>
</div>
<div class="row customs" id="advance">
<select class="customDo">
<option value="advance">Advance</option>
</select>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input class="customValue" type="text" placeholder="value (10)" />
<select class="customForce">
<option value="false">Do not disable website key bindings</option>
<option value="true">Disable website key bindings</option>
</select>
</div>
<div class="row customs" id="reset">
<select class="customDo">
<option value="reset">Reset speed</option>
</select>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input
class="customValue"
type="text"
placeholder="value (1.00)"
disabled
/>
<select class="customForce">
<option value="false">Do not disable website key bindings</option>
<option value="true">Disable website key bindings</option>
</select>
</div>
<div class="row customs" id="fast">
<select class="customDo">
<option value="fast">Preferred speed</option>
</select>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input class="customValue" type="text" placeholder="value (1.80)" />
<select class="customForce">
<option value="false">Do not disable website key bindings</option>
<option value="true">Disable website key bindings</option>
</select>
</div>
<main class="settings-stack">
<section id="customs" class="settings-card">
<div class="section-heading">
<h3>Shortcuts</h3>
<p class="section-intro">
Backspace clears a key. Escape disables optional shortcuts. If a site
steals a shortcut, use a site rule with Override shortcuts (and
per-key blocking) for that URL.
</p>
</div>
<div class="shortcuts-grid">
<div class="shortcut-row" id="display" data-action="display">
<div class="shortcut-label">Show/hide controller</div>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input
class="customValue"
type="text"
placeholder="value"
value="N/A"
disabled
/>
</div>
<div class="shortcut-row" id="move" data-action="move">
<div class="shortcut-label">Move controller</div>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input
class="customValue"
type="text"
placeholder="value"
value="N/A"
disabled
/>
</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>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input
class="customValue"
type="text"
placeholder="value (0.10)"
/>
</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>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input
class="customValue"
type="text"
placeholder="value (0.10)"
/>
</div>
<div class="shortcut-row" id="rewind" data-action="rewind">
<div class="shortcut-label">Rewind</div>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input
class="customValue"
type="text"
placeholder="value (10)"
/>
</div>
<div class="shortcut-row" id="advance" data-action="advance">
<div class="shortcut-label">Advance</div>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input
class="customValue"
type="text"
placeholder="value (10)"
/>
</div>
<div class="shortcut-row" id="reset" data-action="reset">
<div class="shortcut-label">Reset speed</div>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input
class="customValue"
type="text"
placeholder="value"
value="N/A"
disabled
/>
</div>
<div class="shortcut-row" id="fast" data-action="fast">
<div class="shortcut-label">Preferred speed</div>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input
class="customValue"
type="text"
placeholder="value (1.80)"
/>
</div>
<div
class="shortcut-row"
id="toggleSubtitleNudge"
data-action="toggleSubtitleNudge"
>
<div class="shortcut-label">Toggle subtitle nudge</div>
<input
class="customKey"
type="text"
value=""
placeholder="press a key"
/>
<input
class="customValue"
type="text"
placeholder="value"
value="N/A"
disabled
/>
</div>
</div>
<button id="add">Add New</button>
</section>
<select id="addShortcutSelector">
<option value="">Add shortcut&hellip;</option>
</select>
</section>
<section>
<h3>Other</h3>
<div class="row">
<label for="enabled">Enable</label>
<input id="enabled" type="checkbox" />
</div>
<div class="row">
<label for="startHidden">Hide controller by default</label>
<input id="startHidden" type="checkbox" />
</div>
<div class="row">
<label for="rememberSpeed">Remember playback speed</label>
<input id="rememberSpeed" type="checkbox" />
</div>
<div class="row">
<label for="forceLastSavedSpeed"
>Force last saved speed<br />
<em
>Useful for video players that override the speeds set by
VideoSpeed</em
></label
<section id="generalSettings" class="settings-card">
<div class="section-heading">
<h3>Defaults</h3>
<p class="section-intro">Used unless a site rule overrides it.</p>
</div>
<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>
<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>
<input id="audioBoolean" type="checkbox" />
</div>
<div class="defaults-divider"></div>
<h4 class="defaults-sub-heading">Playback</h4>
<div class="row row-checkbox">
<label for="rememberSpeed"
>Remember playback speed<br />
<em
>Stores speed per source so revisiting the same media can restore
it. Separate from &ldquo;Force last saved speed,&rdquo; which
fights players that reset rate.</em
>
</label>
<input id="rememberSpeed" type="checkbox" />
</div>
<div class="row row-checkbox">
<label for="forceLastSavedSpeed"
>Force last saved speed<br />
<em
>Useful when a video player tries to override the speed you set
in Speeder.</em
>
</label>
<input id="forceLastSavedSpeed" type="checkbox" />
</div>
<div class="defaults-divider"></div>
<h4 class="defaults-sub-heading">Controller</h4>
<div class="row row-checkbox">
<label for="startHidden"
>Hide controller by default<br />
<em
>Starts with the overlay hidden; use shortcuts (show/hide,
move) or site behavior to reveal it.</em
>
</label>
<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>
<select id="controllerLocation">
<option value="top-left">Top left</option>
<option value="top-center">Top center</option>
<option value="top-right">Top right</option>
<option value="middle-right">Middle right</option>
<option value="bottom-right">Bottom right</option>
<option value="bottom-center">Bottom center</option>
<option value="bottom-left">Bottom left</option>
<option value="middle-left">Middle left</option>
</select>
</div>
<div class="row">
<label for="controllerOpacity"
>Controller opacity<br />
<em
>0&ndash;1 (decimals). Lower is more transparent. Applies to the
in-page controller only.</em
>
</label>
<input id="controllerOpacity" type="text" value="" />
</div>
<div class="row row-controller-margin">
<label for="controllerMarginTop"
>Controller margin (px)<br />
<em
>Shifts the whole control from its preset position (CSS
margins). Top and bottom. 0&ndash;200.</em
>
</label>
<div class="controller-margin-inputs" aria-label="Controller margin in pixels">
<div class="margin-pad-cell">
<span class="margin-pad-mini">Top</span>
<input id="controllerMarginTop" type="text" inputmode="numeric" placeholder="0" />
</div>
<div class="margin-pad-cell">
<span class="margin-pad-mini">Bottom</span>
<input id="controllerMarginBottom" type="text" inputmode="numeric" placeholder="0" />
</div>
</div>
</div>
<div class="row row-checkbox">
<label for="hideWithControls"
>Hide with controls<br />
<em
>Fade the controller in and out with the video interface:
perfect sync on YouTube, idle-based elsewhere.</em
>
</label>
<input id="hideWithControls" type="checkbox" />
</div>
<div class="row">
<label for="hideWithControlsTimer"
>Auto-hide timer (seconds)<br />
<em
>Seconds of inactivity before hiding: 0.1&ndash;15 for
non-YouTube sites.</em
>
</label>
<input id="hideWithControlsTimer" type="text" placeholder="2" />
</div>
<div class="defaults-divider"></div>
<h4 class="defaults-sub-heading">Subtitle sync</h4>
<div class="row row-checkbox">
<label for="enableSubtitleNudge"
>Enable subtitle nudge<br /><em
>Makes tiny playback changes to help keep subtitles aligned.</em
>
</label>
<input id="enableSubtitleNudge" type="checkbox" />
</div>
<div class="row">
<label for="subtitleNudgeInterval"
>Nudge interval (milliseconds)<br /><em
>How often to nudge: 10&ndash;1000. Smaller values are more
frequent. Default: 50.</em
>
</label>
<input
id="subtitleNudgeInterval"
type="text"
value=""
placeholder="50"
/>
</div>
</section>
<section
id="controlBarsGroup"
class="settings-card control-bars-group"
aria-labelledby="controlBarsGroupHeading"
>
<input id="forceLastSavedSpeed" type="checkbox" />
</div>
<div class="row">
<label for="audioBoolean">Work on audio</label>
<input id="audioBoolean" type="checkbox" />
</div>
<div class="row">
<label for="controllerOpacity">Controller opacity</label>
<input id="controllerOpacity" type="text" value="" />
</div>
<div class="row">
<label for="blacklist"
>Sites on which extension is disabled<br />
(one per line)<br />
<br />
<em>
<a href="https://www.regexpal.com/">Regex</a> is supported.<br />
Be sure it is in "//g" format.<br />
ie: /(.+)youtube\.com(\/*)$/gi
</em>
</label>
<textarea id="blacklist" rows="10" cols="50"></textarea>
</div>
</section>
<div class="section-heading section-heading-major">
<h2 id="controlBarsGroupHeading">Control bars</h2>
<p class="section-intro">
In-page hover bar, extension popup bar, and Lucide icons for
buttons.
</p>
</div>
<div class="control-bars-inner">
<section id="controlBarSettings" class="settings-card settings-card-nested">
<div class="section-heading">
<h3>Hover control bar</h3>
<p class="section-intro">
Drag blocks to reorder. Move between Active and Available to
show or hide buttons.
</p>
</div>
<div class="cb-editor">
<div class="cb-zone">
<div class="cb-zone-label">Active</div>
<div
id="controlBarActive"
class="cb-dropzone cb-active-zone"
></div>
</div>
<div class="cb-zone">
<div class="cb-zone-label">Available</div>
<div
id="controlBarAvailable"
class="cb-dropzone cb-available-zone"
></div>
</div>
</div>
</section>
<section id="nudgeSettings">
<h3>Subtitle Nudge Settings (Experimental - YouTube Only)</h3>
<div class="row">
<label for="enableSubtitleNudge"
>Enable Subtitle Nudge <br /><em
>Periodically 'nudges' video speed by a tiny amount to help keep
subtitles in sync on some sites (e.g. YouTube).</em
>
</label>
<input id="enableSubtitleNudge" type="checkbox" />
</div>
<div class="row">
<label for="subtitleNudgeInterval"
>Nudge Interval (milliseconds) <br /><em
>How often to nudge (e.g., 25-1000). Smaller values are more
frequent. Default: 25.</em
>
</label>
<input
id="subtitleNudgeInterval"
type="text"
value=""
placeholder="25"
/>
</div>
<div class="row">
<label for="subtitleNudgeAmount"
>Nudge Amount (decimal) <br /><em
>How much to change speed by (e.g., 0.001). Very small values
recommended. Default: 0.001.</em
>
</label>
<input
id="subtitleNudgeAmount"
type="text"
value=""
placeholder="0.001"
/>
</div>
</section>
<section id="popupControlBarSettings" class="settings-card settings-card-nested">
<div class="section-heading">
<h3>Popup control bar</h3>
<p class="section-intro">
Configure which buttons appear in the browser popup control bar.
</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>
<input id="showPopupControlBar" type="checkbox" />
</div>
<div class="row row-checkbox">
<label for="popupMatchHoverControls"
>Match hover controls<br />
<em
>When on, the popup copies the hover bar&rsquo;s buttons and
order. When off, customize the popup layout below.</em
>
</label>
<input id="popupMatchHoverControls" type="checkbox" />
</div>
<div id="popupCbEditorWrap" class="cb-editor cb-editor-disabled">
<div class="cb-zone">
<div class="cb-zone-label">Active</div>
<div
id="popupControlBarActive"
class="cb-dropzone cb-active-zone"
></div>
</div>
<div class="cb-zone">
<div class="cb-zone-label">Available</div>
<div
id="popupControlBarAvailable"
class="cb-dropzone cb-available-zone"
></div>
</div>
</div>
</section>
<button id="save">Save</button>
<button id="restore">Restore Defaults</button>
<button id="experimental">Show Experimental Features</button>
<section id="lucideIconSettings" class="settings-card settings-card-nested">
<div class="section-heading">
<h3>Button icons (Lucide)</h3>
<p class="section-intro">
Search icons from the
<a
href="https://lucide.dev"
target="_blank"
rel="noopener noreferrer"
>Lucide</a
>
set (fetched from jsDelivr). Custom icons are cached in local
storage and included when you export settings. Subtitle nudge
icons use two menu entries (enabled and disabled), not the bar
block id
<code>nudge</code>.
</p>
</div>
<div class="row row-lucide-pair">
<label for="lucideIconActionSelect">Controller action</label>
<select id="lucideIconActionSelect"></select>
</div>
<div class="row row-lucide-search-row">
<label for="lucideIconSearch" class="lucide-search-label"
>Search icons</label
>
<div class="lucide-search-field">
<span class="lucide-search-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</span>
<input
type="search"
id="lucideIconSearch"
class="lucide-search-input"
placeholder="Search by name or tag (e.g. star, arrow, media)…"
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<div
id="lucideIconResults"
class="lucide-icon-results"
role="listbox"
aria-label="Matching Lucide icons"
></div>
<p id="lucideIconStatus" class="lucide-icon-status" aria-live="polite"></p>
<div class="lucide-icon-preview-row">
<div
id="lucideIconPreview"
class="lucide-icon-preview"
aria-live="polite"
></div>
<div class="lucide-icon-actions">
<button type="button" id="lucideIconApply" class="lucide-apply">
Apply to action
</button>
<button type="button" id="lucideIconClearAction" class="secondary">
Clear this action
</button>
<button type="button" id="lucideIconClearAll" class="secondary">
Clear all custom icons
</button>
<button type="button" id="lucideIconReloadTags" class="secondary">
Refresh icon list from network
</button>
</div>
</div>
</section>
</div>
</section>
<div id="status"></div>
<section id="siteRules" class="settings-card">
<div class="section-heading">
<h3>Site rules</h3>
<p class="section-intro">
Use plain domains or
<a
href="https://www.regexpal.com/"
target="_blank"
rel="noopener noreferrer"
>Regex</a
>
patterns like <code>/(.+)youtube\.com(\/*)$/gi</code>. Turn on a
row only when you want that group to override the general defaults
above.
</p>
</div>
<div id="siteRulesContainer"></div>
<button id="addSiteRule" type="button">Add Site Rule</button>
</section>
<div id="faq">
<hr />
<template id="siteRuleTemplate">
<div class="site-rule">
<div class="site-rule-header">
<button
type="button"
class="toggle-site-rule"
title="Expand site rule"
aria-label="Expand site rule"
aria-expanded="false"
>
<span class="site-rule-toggle-icon" aria-hidden="true">&hellip;</span>
</button>
<input
type="text"
class="site-pattern"
placeholder="e.g., youtube.com or /regex/gi"
/>
<button type="button" class="remove-site-rule">Remove</button>
</div>
<div class="site-rule-body">
<div class="site-rule-option site-rule-option-checkbox">
<label class="site-rule-split-label">
<span
>Enable Speeder on this site<br /><em
>For this URL pattern only: off blocks when global Speeder
is on (blacklist); on allows when global is off
(whitelist)&mdash;same pairing as Defaults &rarr;
Enable.</em
></span
>
<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
>
<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
>
<select class="site-controllerLocation">
<option value="top-left">Top left</option>
<option value="top-center">Top center</option>
<option value="top-right">Top right</option>
<option value="middle-right">Middle right</option>
<option value="bottom-right">Bottom right</option>
<option value="bottom-center">Bottom center</option>
<option value="bottom-left">Bottom left</option>
<option value="middle-left">Middle left</option>
</select>
</div>
<div class="site-rule-option site-rule-margin-option">
<label
>Controller margin (px):<br /><em
>Shifts the whole control from its preset position (CSS
margins). Top and bottom. 0&ndash;200.</em
></label
>
<div class="controller-margin-inputs">
<div class="margin-pad-cell">
<span class="margin-pad-mini">T</span>
<input type="text" class="site-controllerMarginTop" inputmode="numeric" placeholder="0" />
</div>
<div class="margin-pad-cell">
<span class="margin-pad-mini">B</span>
<input type="text" class="site-controllerMarginBottom" inputmode="numeric" placeholder="0" />
</div>
</div>
</div>
</div>
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span
>Override hide-by-default for this site<br /><em
>When on, the hide-by-default toggle below replaces the
general default for matching URLs.</em
></span
>
<input type="checkbox" class="override-visibility" />
</label>
<div class="site-visibility-container">
<div class="site-rule-option site-rule-option-checkbox">
<label
>Hide controller by default:<br /><em
>Starts with the overlay hidden; use shortcuts
(show/hide, move) or site behavior to reveal it.</em
></label
>
<input type="checkbox" class="site-startHidden" />
</div>
</div>
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span
>Override auto-hide for this site<br /><em
>When on, hide-with-controls and timer below replace
general defaults for matching URLs.</em
></span
>
<input type="checkbox" class="override-autohide" />
</label>
<div class="site-autohide-container">
<div class="site-rule-option site-rule-option-checkbox">
<label class="site-rule-split-label">
<span
>Hide with controls (idle-based)<br /><em
>Fade the controller in and out with the video
interface: perfect sync on YouTube, idle-based
elsewhere.</em
></span
>
<input type="checkbox" class="site-hideWithControls" />
</label>
</div>
<div class="site-rule-option site-rule-option-field">
<label
>Auto-hide timer (0.1&ndash;15s):<br /><em
>Seconds of inactivity before hiding: 0.1&ndash;15 for
non-YouTube sites.</em
></label
>
<input type="text" class="site-hideWithControlsTimer" />
</div>
</div>
</div>
<div class="site-rule-override-section">
<label class="site-override-lead">
<span
>Override playback for this site<br /><em
>When on, remember speed / force / audio below replace
general defaults for matching URLs.</em
></span
>
<input type="checkbox" class="override-playback" />
</label>
<div class="site-playback-container">
<div class="site-rule-option site-rule-option-checkbox">
<label
>Remember playback speed:<br /><em
>Stores speed per source so revisiting the same media
can restore it. Separate from &ldquo;Force last saved
speed,&rdquo; which fights players that reset
rate.</em
></label
>
<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
>
<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
>
<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
>
<input type="checkbox" class="override-opacity" />
</label>
<div class="site-opacity-container">
<div class="site-rule-option site-rule-option-field">
<label
>Controller opacity:<br /><em
>0&ndash;1 (decimals). Lower is more transparent.
Applies to the in-page controller only.</em
></label
>
<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
>
<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
>
<input type="checkbox" class="site-enableSubtitleNudge" />
</div>
<div class="site-rule-option site-rule-option-field">
<label
>Nudge interval (10&ndash;1000ms):<br /><em
>How often to nudge: 10&ndash;1000. Smaller values are
more frequent. Default: 50.</em
></label
>
<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
>
<input type="checkbox" class="override-controlbar" />
</label>
<div class="site-controlbar-container">
<div class="cb-editor">
<div class="cb-zone">
<div class="cb-zone-label">Active</div>
<div class="cb-dropzone cb-active-zone site-cb-active"></div>
</div>
<div class="cb-zone">
<div class="cb-zone-label">Available</div>
<div class="cb-dropzone cb-available-zone site-cb-available"></div>
</div>
</div>
</div>
</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
>
<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
>
<input type="checkbox" class="site-showPopupControlBar" />
</div>
<div class="cb-editor">
<div class="cb-zone">
<div class="cb-zone-label">Active</div>
<div class="cb-dropzone cb-active-zone site-popup-cb-active"></div>
</div>
<div class="cb-zone">
<div class="cb-zone-label">Available</div>
<div class="cb-dropzone cb-available-zone site-popup-cb-available"></div>
</div>
</div>
</div>
</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
>
<input type="checkbox" class="override-shortcuts" />
</label>
<div class="site-shortcuts-container">
<div class="site-shortcuts-rows"></div>
<select
class="site-add-shortcut-selector"
aria-label="Add shortcut for this site"
>
<option value="">Add shortcut&hellip;</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>
<h4>Extension controls not appearing?</h4>
<p>
This extension is only compatible with HTML5 audio and video. If you
don't see the controls showing up, chances are you are viewing a Flash
content. If you want to confirm, try right-clicking on the content and
inspect the menu: if it mentions flash, then that's the issue. That
said, <b>most sites will fallback to HTML5</b> if they detect that Flash
is not available. You can try manually disabling Flash from the browser.
</p>
<section class="settings-card action-card">
<div class="section-heading">
<h3>Actions</h3>
<p class="section-intro">Save, restore, export, or import settings.</p>
</div>
<div class="action-row">
<button id="save">Save Changes</button>
<button id="restore">Restore Defaults</button>
<button id="exportSettings">Export Settings</button>
<button id="importSettings">Import Settings</button>
</div>
<div id="status" role="status" aria-live="polite"></div>
</section>
<section id="faq" class="settings-card info-card">
<h4>Extension controls not appearing?</h4>
<p>
This extension only works with HTML5 audio and video. If the
controls never appear, you may be looking at Flash content instead.
Right-click the player to check: if the menu mentions Flash, that
is the issue. Most sites will fall back to HTML5 when Flash is not
available, so disabling Flash in the browser can help.
</p>
</section>
</main>
</div>
</body>
</html>
+1784 -290
View File
@@ -1,334 +1,1739 @@
var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var controllerUtils = speederShared.controllerUtils || {};
var keyBindingUtils = speederShared.keyBindings || {};
var popupControlUtils = speederShared.popupControls || {};
var keyBindings = [];
var bindingCodeAliases = {
Space: "Space",
ArrowLeft: "Left",
ArrowUp: "Up",
ArrowRight: "Right",
ArrowDown: "Down",
Numpad0: "Num 0",
Numpad1: "Num 1",
Numpad2: "Num 2",
Numpad3: "Num 3",
Numpad4: "Num 4",
Numpad5: "Num 5",
Numpad6: "Num 6",
Numpad7: "Num 7",
Numpad8: "Num 8",
Numpad9: "Num 9",
NumpadMultiply: "Num *",
NumpadAdd: "Num +",
NumpadSubtract: "Num -",
NumpadDecimal: "Num .",
NumpadDivide: "Num /",
Backquote: "`",
Minus: "-",
Equal: "=",
BracketLeft: "[",
BracketRight: "]",
Backslash: "\\",
Semicolon: ";",
Quote: "'",
Comma: ",",
Period: ".",
Slash: "/"
};
var modifierKeys = new Set([
"Alt",
"AltGraph",
"Control",
"Fn",
"Hyper",
"Meta",
"OS",
"Shift"
]);
var controllerLocations = Array.isArray(controllerUtils.controllerLocations)
? controllerUtils.controllerLocations.slice()
: [
"top-left",
"top-center",
"top-right",
"middle-right",
"bottom-right",
"bottom-center",
"bottom-left",
"middle-left"
];
var controllerButtonDefs = {
rewind: { icon: "\u00AB", name: "Rewind" },
slower: { icon: "\u2212", name: "Decrease speed" },
faster: { icon: "+", name: "Increase speed" },
advance: { icon: "\u00BB", name: "Advance" },
display: { icon: "\u00D7", name: "Close controller" },
reset: { icon: "\u21BB", name: "Reset speed" },
fast: { icon: "\u2605", name: "Preferred speed" },
nudge: { icon: "\u2713", name: "Subtitle nudge" },
pause: { icon: "\u23EF", name: "Play / Pause" },
muted: { icon: "M", name: "Mute / Unmute" },
louder: { icon: "+", name: "Increase volume" },
softer: { icon: "\u2212", name: "Decrease volume" },
mark: { icon: "\u2691", name: "Set marker" },
jump: { icon: "\u21E5", name: "Jump to marker" },
settings: { icon: "\u2699", name: "Settings" },
};
var popupExcludedButtonIds = new Set(["settings"]);
/** Lucide picker only — not control-bar blocks (chip uses subtitleNudgeOn/Off). */
var lucideSubtitleNudgeActionLabels = {
subtitleNudgeOn: "Subtitle nudge — enabled",
subtitleNudgeOff: "Subtitle nudge — disabled"
};
function sanitizePopupButtonOrder(buttonIds) {
return popupControlUtils.sanitizeButtonOrder(
buttonIds,
controllerButtonDefs,
popupExcludedButtonIds
);
}
/** Cached custom Lucide SVGs (mirrors chrome.storage.local customButtonIcons). */
var customButtonIconsLive = {};
function fillControlBarIconElement(icon, buttonId) {
if (!icon || !buttonId) return;
var doc = icon.ownerDocument || document;
if (buttonId === "nudge") {
vscClearElement(icon);
icon.className = "cb-icon cb-icon-nudge-pair";
function nudgeChipMarkup(action) {
var c = customButtonIconsLive[action];
if (c && c.svg) return c.svg;
if (typeof vscIconSvgString === "function") {
return vscIconSvgString(action, 14) || "";
}
return "";
}
function appendChip(action, stateKey) {
var sp = document.createElement("span");
sp.className = "cb-nudge-chip";
sp.setAttribute("data-nudge-state", stateKey);
var inner = nudgeChipMarkup(action);
if (inner) {
var wrap = vscCreateSvgWrap(doc, inner, "vsc-btn-icon");
if (wrap) {
sp.appendChild(wrap);
}
}
icon.appendChild(sp);
}
appendChip("subtitleNudgeOn", "on");
var sep = document.createElement("span");
sep.className = "cb-nudge-sep";
sep.textContent = "/";
icon.appendChild(sep);
appendChip("subtitleNudgeOff", "off");
return;
}
icon.className = "cb-icon";
var custom = customButtonIconsLive[buttonId];
if (custom && custom.svg) {
if (vscSetSvgContent(icon, custom.svg)) return;
}
if (typeof vscIconSvgString === "function") {
var svgHtml = vscIconSvgString(buttonId, 16);
if (svgHtml) {
if (vscSetSvgContent(icon, svgHtml)) return;
}
}
vscClearElement(icon);
var def = controllerButtonDefs[buttonId];
icon.textContent = (def && def.icon) || "?";
}
function createDefaultBinding(action, code, value) {
return {
action: action,
code: code,
value: value,
force: false,
predefined: true
};
}
var tcDefaults = {
speed: 1.0, // default:
displayKeyCode: 86, // default: V
rememberSpeed: false, // default: false
audioBoolean: false, // default: false
startHidden: false, // default: false
forceLastSavedSpeed: false, //default: false
enabled: true, // default enabled
controllerOpacity: 0.3, // default: 0.3
speed: 1.0,
lastSpeed: 1.0,
rememberSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
forceLastSavedSpeed: false,
enabled: true,
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
keyBindings: [
{ action: "display", key: 86, value: 0, force: false, predefined: true }, // V
{ action: "slower", key: 83, value: 0.1, force: false, predefined: true }, // S
{ action: "faster", key: 68, value: 0.1, force: false, predefined: true }, // D
{ action: "rewind", key: 90, value: 10, force: false, predefined: true }, // Z
{ action: "advance", key: 88, value: 10, force: false, predefined: true }, // X
{ action: "reset", key: 82, value: 1, force: false, predefined: true }, // R
{ action: "fast", key: 71, value: 1.8, force: false, predefined: true } // G
createDefaultBinding("display", "KeyV", 0),
createDefaultBinding("move", "KeyP", 0),
createDefaultBinding("slower", "KeyS", 0.1),
createDefaultBinding("faster", "KeyD", 0.1),
createDefaultBinding("rewind", "KeyZ", 10),
createDefaultBinding("advance", "KeyX", 10),
createDefaultBinding("reset", "KeyR", 1),
createDefaultBinding("fast", "KeyG", 1.8),
createDefaultBinding("toggleSubtitleNudge", "KeyN", 0)
],
blacklist: `www.instagram.com
twitter.com
imgur.com
teams.microsoft.com
`.replace(regStrip, ""),
// ADDED: Nudge defaults
enableSubtitleNudge: true,
subtitleNudgeInterval: 25,
siteRules: [
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
enabled: true,
enableSubtitleNudge: true,
subtitleNudgeInterval: 50
},
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
}
],
controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
showPopupControlBar: true,
popupMatchHoverControls: true,
popupControllerButtons: ["rewind", "slower", "faster", "advance", "display"],
enableSubtitleNudge: false,
subtitleNudgeInterval: 50,
subtitleNudgeAmount: 0.001
};
var keyBindings = []; // This is populated during save/restore
var keyCodeAliases = {
/* ... same as your original ... */ 0: "null",
null: "null",
undefined: "null",
32: "Space",
37: "Left",
38: "Up",
39: "Right",
40: "Down",
96: "Num 0",
97: "Num 1",
98: "Num 2",
99: "Num 3",
100: "Num 4",
101: "Num 5",
102: "Num 6",
103: "Num 7",
104: "Num 8",
105: "Num 9",
106: "Num *",
107: "Num +",
109: "Num -",
110: "Num .",
111: "Num /",
112: "F1",
113: "F2",
114: "F3",
115: "F4",
116: "F5",
117: "F6",
118: "F7",
119: "F8",
120: "F9",
121: "F10",
122: "F11",
123: "F12",
186: ";",
188: "<",
189: "-",
187: "+",
190: ">",
191: "/",
192: "~",
219: "[",
220: "\\",
221: "]",
222: "'",
59: ";",
61: "+",
173: "-"
const 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"
};
function recordKeyPress(e) {
/* ... same as your original ... */
if (
(e.keyCode >= 48 && e.keyCode <= 57) ||
(e.keyCode >= 65 && e.keyCode <= 90) ||
keyCodeAliases[e.keyCode]
) {
e.target.value =
keyCodeAliases[e.keyCode] || String.fromCharCode(e.keyCode);
e.target.keyCode = e.keyCode;
e.preventDefault();
e.stopPropagation();
} else if (e.keyCode === 8) {
e.target.value = "";
} else if (e.keyCode === 27) {
e.target.value = "null";
e.target.keyCode = null;
const speedBindingActions = ["slower", "faster", "fast", "softer", "louder"];
const requiredShortcutActions = new Set(["slower", "faster"]);
function formatSpeedBindingDisplay(action, value) {
if (!speedBindingActions.includes(action)) {
return value;
}
var n = Number(value);
if (!isFinite(n)) {
return value;
}
return n.toFixed(2);
}
function getDefaultShortcutValue(action) {
if (action === "louder" || action === "softer") {
return 0.1;
}
var defaultBinding = tcDefaults.keyBindings.find(function (binding) {
return binding.action === action;
});
if (defaultBinding && Number.isFinite(Number(defaultBinding.value))) {
return Number(defaultBinding.value);
}
return 0;
}
function resolveShortcutValue(action, value) {
if (value === undefined || value === null) {
return getDefaultShortcutValue(action);
}
var numericValue = Number(value);
if (Number.isFinite(numericValue)) {
return numericValue;
}
return 0;
}
const customActionsNoValues = [
"reset",
"display",
"move",
"muted",
"pause",
"mark",
"jump",
"toggleSubtitleNudge"
];
function refreshAddShortcutSelector() {
const selector = document.getElementById("addShortcutSelector");
if (!selector) return;
// Clear existing options except the first one
while (selector.options.length > 1) {
selector.remove(1);
}
// Find all currently used actions
const usedActions = new Set();
document.querySelectorAll(".shortcut-row").forEach((row) => {
const action = row.dataset.action;
if (action) {
usedActions.add(action);
}
});
// Add all unused actions
Object.keys(actionLabels).forEach((action) => {
if (!usedActions.has(action)) {
const option = document.createElement("option");
option.value = action;
option.text = actionLabels[action];
selector.appendChild(option);
}
});
// If no available actions, hide or disable the selector
if (selector.options.length === 1) {
selector.disabled = true;
selector.options[0].text = "All shortcuts added";
} else {
selector.disabled = false;
selector.options[0].text = "Add shortcut\u2026";
}
}
function inputFilterNumbersOnly(e) {
/* ... same as your original ... */
var char = String.fromCharCode(e.keyCode);
if (!/[\d\.]$/.test(char) || !/^\d+(\.\d*)?$/.test(e.target.value + char)) {
e.preventDefault();
e.stopPropagation();
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 inputFocus(e) {
/* ... same as your original ... */ e.target.value = "";
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 inputBlur(e) {
/* ... same as your original ... */ e.target.value =
keyCodeAliases[e.target.keyCode] || String.fromCharCode(e.target.keyCode);
function ensureDefaultBinding(storage, action, code, value) {
if (storage.keyBindings.some((item) => item.action === action)) return;
storage.keyBindings.push(createDefaultBinding(action, code, value));
}
// function updateShortcutInputText(inputId, keyCode) { /* ... same as your original ... */ } // Not directly used in provided options.js logic flow
function updateCustomShortcutInputText(inputItem, keyCode) {
/* ... same as your original ... */ inputItem.value =
keyCodeAliases[keyCode] || String.fromCharCode(keyCode);
inputItem.keyCode = keyCode;
}
var customActionsNoValues = ["pause", "muted", "mark", "jump", "display"]; // Original
function add_shortcut() {
/* ... same as your original ... */
var html = `<select class="customDo"><option value="slower">Decrease speed</option><option value="faster">Increase speed</option><option value="rewind">Rewind</option><option value="advance">Advance</option><option value="reset">Reset speed</option><option value="fast">Preferred speed</option><option value="muted">Mute</option><option value="pause">Pause</option><option value="mark">Set marker</option><option value="jump">Jump to marker</option><option value="display">Show/hide controller</option></select><input class="customKey" type="text" placeholder="press a key"/><input class="customValue" type="text" placeholder="value (0.10)"/><select class="customForce"><option value="false">Do not disable website key bindings</option><option value="true">Disable website key bindings</option></select><button class="removeParent">X</button>`;
var div = document.createElement("div");
div.setAttribute("class", "row customs");
div.innerHTML = html;
var customs_element = document.getElementById("customs");
customs_element.insertBefore(
div,
customs_element.children[customs_element.childElementCount - 1]
function normalizeControllerLocation(location) {
return controllerUtils.normalizeControllerLocation(
location,
tcDefaults.controllerLocation
);
}
function createKeyBindings(item) {
/* ... same as your original ... */
const action = item.querySelector(".customDo").value;
const key = item.querySelector(".customKey").keyCode;
const value = Number(item.querySelector(".customValue").value);
const force = item.querySelector(".customForce").value;
const predefined = !!item.id;
keyBindings.push({
action: action,
key: key,
value: value,
force: force,
predefined: predefined
function clampMarginPxInput(el, fallback) {
return controllerUtils.clampControllerMarginPx(el && el.value, fallback);
}
function parseFiniteNumberOrFallback(value, fallback) {
var numericValue = parseFloat(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function updateSiteRuleToggleIcon(toggleButton, action) {
if (!toggleButton) return;
var iconEl = toggleButton.querySelector(".site-rule-toggle-icon");
if (!iconEl) return;
if (typeof vscIconSvgString === "function" && typeof vscSetSvgContent === "function") {
var svgHtml = vscIconSvgString(action, 16);
if (svgHtml && vscSetSvgContent(iconEl, svgHtml)) {
return;
}
}
iconEl.textContent = action === "chevronUp" ? "\u2212" : "\u2026";
}
function setSiteRuleExpandedState(ruleEl, expanded) {
if (!ruleEl) return;
var ruleBody = ruleEl.querySelector(".site-rule-body");
var toggleButton = ruleEl.querySelector(".toggle-site-rule");
if (ruleBody) {
ruleBody.style.display = expanded ? "block" : "none";
}
ruleEl.classList.toggle("collapsed", !expanded);
if (!toggleButton) return;
var label = expanded ? "Collapse site rule" : "Expand site rule";
toggleButton.title = label;
toggleButton.setAttribute("aria-label", label);
toggleButton.setAttribute("aria-expanded", expanded ? "true" : "false");
updateSiteRuleToggleIcon(toggleButton, expanded ? "chevronUp" : "moreHorizontal");
}
function setSiteOverrideContainerState(container, enabled) {
if (!container) return;
container.classList.toggle("site-override-disabled", !enabled);
container.setAttribute("aria-disabled", enabled ? "false" : "true");
Array.prototype.forEach.call(
container.querySelectorAll("input, select, textarea, button"),
function (control) {
control.disabled = !enabled;
}
);
Array.prototype.forEach.call(
container.querySelectorAll(".cb-block"),
function (block) {
block.draggable = enabled;
}
);
}
function applySiteRuleOverrideState(ruleEl, checkboxClass, containerClass) {
if (!ruleEl) return;
var checkbox = ruleEl.querySelector("." + checkboxClass);
var container = ruleEl.querySelector("." + containerClass);
if (!container) return;
container.style.display = "block";
setSiteOverrideContainerState(container, checkbox ? checkbox.checked : false);
}
function syncSiteRuleField(ruleEl, rule, key, isCheckbox) {
var input = ruleEl.querySelector(".site-" + key);
if (!input) return;
var globalEl = document.getElementById(key);
var value;
if (rule && rule[key] !== undefined) {
value = rule[key];
} else if (globalEl) {
value = isCheckbox ? globalEl.checked : globalEl.value;
} else {
return;
}
if (isCheckbox) input.checked = Boolean(value);
else input.value = value;
}
function normalizeBindingKey(key) {
return keyBindingUtils.normalizeBindingKey(key);
}
function getLegacyKeyCode(binding) {
return keyBindingUtils.getLegacyKeyCode(binding);
}
function legacyBindingKeyToCode(key) {
return keyBindingUtils.legacyBindingKeyToCode(key);
}
function legacyKeyCodeToCode(keyCode) {
return keyBindingUtils.legacyKeyCodeToCode(keyCode);
}
function inferBindingCode(binding, fallbackCode) {
return keyBindingUtils.inferBindingCode(binding, fallbackCode);
}
function createDisabledBinding() {
return {
code: null,
disabled: true
};
}
function normalizeStoredBinding(binding, fallbackCode) {
if (!binding) {
if (!fallbackCode) return null;
return {
code: fallbackCode,
disabled: false
};
}
if (
binding.disabled === true ||
(binding.code === null &&
binding.key === null &&
binding.keyCode === null)
) {
return createDisabledBinding();
}
var normalizedCode = inferBindingCode(binding, fallbackCode);
if (!normalizedCode) {
return null;
}
var normalized = {
code: normalizedCode,
disabled: false
};
return normalized;
}
function formatBindingCode(code) {
if (typeof code !== "string" || code.length === 0) return "";
if (bindingCodeAliases[code]) return bindingCodeAliases[code];
if (/^Key[A-Z]$/.test(code)) return code.substring(3);
if (/^Digit[0-9]$/.test(code)) return code.substring(5);
if (/^F([1-9]|1[0-2])$/.test(code)) return code;
return code;
}
function getBindingLabel(binding) {
if (!binding) return "";
if (binding.disabled) return "";
return formatBindingCode(binding.code);
}
function setShortcutInputBinding(input, binding) {
input.vscBinding = binding ? Object.assign({}, binding) : null;
input.value = getBindingLabel(binding);
}
function captureBindingFromEvent(event) {
if (modifierKeys.has(event.key)) return null;
if (typeof event.code !== "string" || event.code.length === 0) return null;
return {
code: event.code,
disabled: false
};
}
function recordKeyPress(event) {
if (event.key === "Tab") return;
if (event.key === "Backspace") {
setShortcutInputBinding(event.target, null);
event.preventDefault();
event.stopPropagation();
return;
}
if (event.key === "Escape") {
setShortcutInputBinding(event.target, createDisabledBinding());
event.preventDefault();
event.stopPropagation();
return;
}
var binding = captureBindingFromEvent(event);
if (!binding) return;
setShortcutInputBinding(event.target, binding);
event.preventDefault();
event.stopPropagation();
}
function inputFilterNumbersOnly(event) {
var char = event.key;
if (
typeof char !== "string" ||
char.length !== 1 ||
!/[\d\.]$/.test(char) ||
!/^\d+(\.\d*)?$/.test(event.target.value + char)
) {
event.preventDefault();
event.stopPropagation();
}
}
function inputFocus(event) {
event.target.value = "";
}
function inputBlur(event) {
setShortcutInputBinding(event.target, event.target.vscBinding || null);
}
function updateCustomShortcutInputText(inputItem, bindingOrKeyCode) {
if (
bindingOrKeyCode &&
typeof bindingOrKeyCode === "object" &&
!Array.isArray(bindingOrKeyCode)
) {
setShortcutInputBinding(inputItem, bindingOrKeyCode);
return;
}
if (typeof bindingOrKeyCode === "string") {
setShortcutInputBinding(inputItem, { code: bindingOrKeyCode, disabled: false });
return;
}
setShortcutInputBinding(
inputItem,
normalizeStoredBinding({ keyCode: bindingOrKeyCode })
);
}
function appendSelectOptions(select, options) {
options.forEach(function (optionData) {
var option = document.createElement("option");
option.value = optionData.value;
option.textContent = optionData.label;
select.appendChild(option);
});
}
function add_shortcut(action, value) {
if (!action) return;
var div = document.createElement("div");
div.setAttribute("class", "shortcut-row customs");
div.dataset.action = action;
var actionLabel = document.createElement("div");
actionLabel.className = "shortcut-label";
actionLabel.textContent = actionLabels[action] || action;
var keyInput = document.createElement("input");
keyInput.className = "customKey";
keyInput.type = "text";
keyInput.placeholder = "press a key";
var valueInput = document.createElement("input");
valueInput.className = "customValue";
valueInput.type = "text";
valueInput.placeholder = "value";
if (customActionsNoValues.includes(action)) {
valueInput.value = "N/A";
valueInput.disabled = true;
} else {
valueInput.value = formatSpeedBindingDisplay(
action,
resolveShortcutValue(action, value)
);
}
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(removeButton);
var customsElement = document.querySelector(".shortcuts-grid");
customsElement.appendChild(div);
refreshAddShortcutSelector();
}
function createKeyBindings(item) {
var action = item.dataset.action || item.querySelector(".customDo").value;
var input = item.querySelector(".customKey");
var valueInput = item.querySelector(".customValue");
var predefined = !!item.id;
var binding = normalizeStoredBinding(input.vscBinding);
if (!binding) {
if (requiredShortcutActions.has(action)) {
return {
valid: false,
message:
"Error: Shortcut for " +
(actionLabels[action] || action) +
" cannot be empty. Unable to save"
};
}
binding = createDisabledBinding();
}
if (binding.disabled === true && requiredShortcutActions.has(action)) {
return {
valid: false,
message:
"Error: Shortcut for " +
(actionLabels[action] || action) +
" cannot be empty. Unable to save"
};
}
keyBindings.push({
action: action,
code: binding.code,
disabled: binding.disabled === true,
value: customActionsNoValues.includes(action)
? 0
: Number(valueInput.value),
force: false,
predefined: predefined
});
return { valid: true };
}
function validate() {
/* ... same as your original ... */
var valid = true;
var status = document.getElementById("status");
document
.getElementById("blacklist")
.value.split("\n")
.forEach((match) => {
match = match.replace(regStrip, "");
if (match.startsWith("/")) {
try {
new RegExp(match);
} catch (err) {
status.textContent =
"Error: Invalid blacklist regex: " + match + ". Unable to save";
valid = false;
return;
// Validate site rules patterns
document.querySelectorAll(".site-rule").forEach((ruleEl) => {
var pattern = ruleEl.querySelector(".site-pattern").value.trim();
if (pattern.length === 0) return;
if (pattern.startsWith("/")) {
try {
var lastSlash = pattern.lastIndexOf("/");
if (lastSlash > 0) {
new RegExp(pattern.substring(1, lastSlash), pattern.substring(lastSlash + 1));
}
} catch (err) {
status.textContent =
"Error: Invalid site rule regex: " + pattern + ". Unable to save";
valid = false;
return;
}
});
}
});
return valid;
}
// MODIFIED: save_options to include nudge settings
function save_options() {
if (validate() === false) return;
keyBindings = []; // Reset global keyBindings before populating from DOM
Array.from(document.querySelectorAll(".customs")).forEach((item) =>
createKeyBindings(item)
keyBindings = [];
var status = document.getElementById("status");
var saveError = null;
// Collect shortcuts from the main shortcuts section (both default and custom)
Array.from(document.querySelectorAll("#customs .shortcut-row")).forEach((item) => {
if (saveError) return;
var result = createKeyBindings(item);
if (!result.valid) saveError = result.message;
});
if (saveError) {
status.textContent = saveError;
return;
}
var settings = {};
settings.rememberSpeed = document.getElementById("rememberSpeed").checked;
settings.forceLastSavedSpeed =
document.getElementById("forceLastSavedSpeed").checked;
settings.audioBoolean = document.getElementById("audioBoolean").checked;
settings.enabled = document.getElementById("enabled").checked;
settings.startHidden = document.getElementById("startHidden").checked;
settings.hideWithControls = document.getElementById("hideWithControls").checked;
settings.hideWithControlsTimer =
Math.min(15, Math.max(0.1, parseFloat(document.getElementById("hideWithControlsTimer").value) || tcDefaults.hideWithControlsTimer));
// Sync back to the legacy key if it exists, for backward compatibility
settings.hideWithYouTubeControls = settings.hideWithControls;
if (settings.hideWithControlsTimer < 0.1) settings.hideWithControlsTimer = 0.1;
if (settings.hideWithControlsTimer > 15) settings.hideWithControlsTimer = 15;
settings.controllerLocation = normalizeControllerLocation(
document.getElementById("controllerLocation").value
);
settings.controllerOpacity =
parseFiniteNumberOrFallback(
document.getElementById("controllerOpacity").value,
tcDefaults.controllerOpacity
);
settings.controllerMarginTop = clampMarginPxInput(
document.getElementById("controllerMarginTop"),
tcDefaults.controllerMarginTop
);
settings.controllerMarginBottom = clampMarginPxInput(
document.getElementById("controllerMarginBottom"),
tcDefaults.controllerMarginBottom
);
var s = {}; // Object to hold all settings to be saved
s.rememberSpeed = document.getElementById("rememberSpeed").checked;
s.forceLastSavedSpeed = document.getElementById(
"forceLastSavedSpeed"
).checked;
s.audioBoolean = document.getElementById("audioBoolean").checked;
s.enabled = document.getElementById("enabled").checked;
s.startHidden = document.getElementById("startHidden").checked;
s.controllerOpacity = document.getElementById("controllerOpacity").value;
s.blacklist = document
.getElementById("blacklist")
.value.replace(regStrip, "");
s.keyBindings = keyBindings; // Use the populated global keyBindings
// ADDED: Save nudge settings
s.enableSubtitleNudge = document.getElementById(
"enableSubtitleNudge"
).checked;
s.subtitleNudgeInterval =
settings.keyBindings = keyBindings;
settings.enableSubtitleNudge =
document.getElementById("enableSubtitleNudge").checked;
settings.subtitleNudgeInterval =
parseInt(document.getElementById("subtitleNudgeInterval").value, 10) ||
tcDefaults.subtitleNudgeInterval;
s.subtitleNudgeAmount =
parseFloat(document.getElementById("subtitleNudgeAmount").value) ||
tcDefaults.subtitleNudgeAmount;
// Basic validation for nudge interval and amount
if (s.subtitleNudgeInterval < 10) s.subtitleNudgeInterval = 10; // Min 10ms
if (s.subtitleNudgeAmount <= 0 || s.subtitleNudgeAmount > 0.1)
s.subtitleNudgeAmount = tcDefaults.subtitleNudgeAmount;
settings.subtitleNudgeAmount = tcDefaults.subtitleNudgeAmount;
// Remove old flat settings (original logic)
chrome.storage.sync.remove([
"resetSpeed",
"speedStep",
"fastSpeed",
"rewindTime",
"advanceTime",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode"
]);
if (settings.subtitleNudgeInterval < 10) {
settings.subtitleNudgeInterval = 10;
}
if (settings.subtitleNudgeInterval > 1000) {
settings.subtitleNudgeInterval = 1000;
}
chrome.storage.sync.set(s, function () {
var status = document.getElementById("status");
status.textContent = "Options saved";
setTimeout(function () {
status.textContent = "";
}, 1000);
settings.controllerButtons = getControlBarOrder();
settings.showPopupControlBar =
document.getElementById("showPopupControlBar").checked;
settings.popupMatchHoverControls =
document.getElementById("popupMatchHoverControls").checked;
settings.popupControllerButtons = sanitizePopupButtonOrder(getPopupControlBarOrder());
// Collect site rules
settings.siteRules = [];
document.querySelectorAll(".site-rule").forEach((ruleEl) => {
var pattern = ruleEl.querySelector(".site-pattern").value.trim();
if (pattern.length === 0) return;
var rule = { pattern: pattern };
// Handle Enable toggle
rule.enabled = ruleEl.querySelector(".site-enabled").checked;
if (ruleEl.querySelector(".override-placement").checked) {
rule.controllerLocation = normalizeControllerLocation(
ruleEl.querySelector(".site-controllerLocation").value
);
rule.controllerMarginTop = clampMarginPxInput(
ruleEl.querySelector(".site-controllerMarginTop"),
clampMarginPxInput(
document.getElementById("controllerMarginTop"),
tcDefaults.controllerMarginTop
)
);
rule.controllerMarginBottom = clampMarginPxInput(
ruleEl.querySelector(".site-controllerMarginBottom"),
clampMarginPxInput(
document.getElementById("controllerMarginBottom"),
tcDefaults.controllerMarginBottom
)
);
}
if (ruleEl.querySelector(".override-visibility").checked) {
rule.startHidden = ruleEl.querySelector(".site-startHidden").checked;
}
if (ruleEl.querySelector(".override-autohide").checked) {
rule.hideWithControls = ruleEl.querySelector(".site-hideWithControls").checked;
var st = parseFloat(
ruleEl.querySelector(".site-hideWithControlsTimer").value
);
rule.hideWithControlsTimer = Math.min(
15,
Math.max(0.1, Number.isFinite(st) ? st : settings.hideWithControlsTimer)
);
}
if (ruleEl.querySelector(".override-playback").checked) {
rule.rememberSpeed = ruleEl.querySelector(".site-rememberSpeed").checked;
rule.forceLastSavedSpeed =
ruleEl.querySelector(".site-forceLastSavedSpeed").checked;
rule.audioBoolean = ruleEl.querySelector(".site-audioBoolean").checked;
}
if (ruleEl.querySelector(".override-opacity").checked) {
rule.controllerOpacity =
parseFiniteNumberOrFallback(
ruleEl.querySelector(".site-controllerOpacity").value,
settings.controllerOpacity
);
}
if (ruleEl.querySelector(".override-subtitleNudge").checked) {
rule.enableSubtitleNudge =
ruleEl.querySelector(".site-enableSubtitleNudge").checked;
var nudgeIv = parseInt(
ruleEl.querySelector(".site-subtitleNudgeInterval").value,
10
);
rule.subtitleNudgeInterval = Math.min(
1000,
Math.max(
10,
Number.isFinite(nudgeIv) ? nudgeIv : settings.subtitleNudgeInterval
)
);
}
if (ruleEl.querySelector(".override-controlbar").checked) {
var activeZone = ruleEl.querySelector(".site-cb-active");
if (activeZone) {
rule.controllerButtons = readControlBarOrder(activeZone);
}
}
if (ruleEl.querySelector(".override-popup-controlbar").checked) {
rule.showPopupControlBar =
ruleEl.querySelector(".site-showPopupControlBar").checked;
var popupActiveZone = ruleEl.querySelector(".site-popup-cb-active");
if (popupActiveZone) {
rule.popupControllerButtons = sanitizePopupButtonOrder(
readControlBarOrder(popupActiveZone)
);
}
}
if (ruleEl.querySelector(".override-shortcuts").checked) {
var shortcuts = [];
ruleEl.querySelectorAll(".site-shortcuts-container .customs").forEach((shortcutRow) => {
if (saveError) return;
var action = shortcutRow.dataset.action;
var keyInput = shortcutRow.querySelector(".customKey");
var valueInput = shortcutRow.querySelector(".customValue");
var forceCheckbox = shortcutRow.querySelector(".customForce");
var binding = normalizeStoredBinding(keyInput.vscBinding);
if (!binding) {
if (requiredShortcutActions.has(action)) {
saveError =
"Error: Site rule shortcut for " +
(actionLabels[action] || action) +
" cannot be empty. Unable to save";
return;
}
binding = createDisabledBinding();
}
if (binding.disabled === true && requiredShortcutActions.has(action)) {
saveError =
"Error: Site rule shortcut for " +
(actionLabels[action] || action) +
" cannot be empty. Unable to save";
return;
}
shortcuts.push({
action: action,
code: binding.code,
disabled: binding.disabled === true,
value: customActionsNoValues.includes(action)
? 0
: Number(valueInput.value),
force: forceCheckbox ? forceCheckbox.checked : false
});
});
if (saveError) return;
if (shortcuts.length > 0) rule.shortcuts = shortcuts;
}
settings.siteRules.push(rule);
});
// Legacy keys to remove
const legacyKeys = [
"resetSpeed", "speedStep", "fastSpeed", "rewindTime", "advanceTime",
"resetKeyCode", "slowerKeyCode", "fasterKeyCode", "rewindKeyCode",
"advanceKeyCode", "fastKeyCode", "blacklist"
];
chrome.storage.sync.remove(legacyKeys, function () {
chrome.storage.sync.set(settings, function () {
status.textContent = "Options saved";
setTimeout(function () {
status.textContent = "";
}, 1000);
});
});
}
// MODIFIED: restore_options to include nudge settings
function ensureAllDefaultBindings(storage) {
tcDefaults.keyBindings.forEach((binding) => {
ensureDefaultBinding(storage, binding.action, binding.code, binding.value);
});
}
function addSiteRuleShortcut(rowsEl, action, binding, value, force) {
if (!rowsEl) return;
var div = document.createElement("div");
div.setAttribute("class", "shortcut-row customs");
div.dataset.action = action;
var actionLabel = document.createElement("div");
actionLabel.className = "shortcut-label";
var actionLabelText = actionLabels[action] || action;
if (action === "toggleSubtitleNudge") {
var ruleEl = rowsEl.closest(".site-rule");
var pattern = ruleEl ? ruleEl.querySelector(".site-pattern").value : "";
if (!pattern.toLowerCase().includes("youtube.com")) {
actionLabelText += " (only for YouTube embeds)";
}
}
actionLabel.textContent = actionLabelText;
var keyInput = document.createElement("input");
keyInput.className = "customKey";
keyInput.type = "text";
keyInput.placeholder = "press a key";
updateCustomShortcutInputText(keyInput, binding || createDisabledBinding());
var valueInput = document.createElement("input");
valueInput.className = "customValue";
valueInput.type = "text";
valueInput.placeholder = "value (0.10)";
if (customActionsNoValues.includes(action)) {
valueInput.value = "N/A";
valueInput.disabled = true;
} else {
valueInput.value = formatSpeedBindingDisplay(
action,
resolveShortcutValue(action, value)
);
}
var forceLabel = document.createElement("label");
forceLabel.className = "force-label";
forceLabel.title = "Prevent website from capturing this key";
var forceCheckbox = document.createElement("input");
forceCheckbox.type = "checkbox";
forceCheckbox.className = "customForce";
forceCheckbox.checked = force === true || force === "true";
var forceText = document.createElement("span");
forceText.textContent = "Block site from capturing keypress";
forceText.className = "force-text";
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);
}
function createSiteRule(rule) {
var template = document.getElementById("siteRuleTemplate");
var clone = template.content.cloneNode(true);
var ruleEl = clone.querySelector(".site-rule");
var pattern = rule && rule.pattern ? rule.pattern : "";
ruleEl.querySelector(".site-pattern").value = pattern;
// Make the rule body collapsed by default
setSiteRuleExpandedState(ruleEl, false);
var enabledCheckbox = ruleEl.querySelector(".site-enabled");
var contentEl = ruleEl.querySelector(".site-rule-content");
function updateDisabledState() {
if (enabledCheckbox.checked) {
contentEl.classList.remove("disabled-rule");
} else {
contentEl.classList.add("disabled-rule");
}
}
enabledCheckbox.addEventListener("change", updateDisabledState);
if (rule) {
if (rule.enabled !== undefined) {
enabledCheckbox.checked = rule.enabled;
} else if (rule.disableExtension !== undefined) {
enabledCheckbox.checked = !rule.disableExtension;
} else {
enabledCheckbox.checked = true;
}
} else {
enabledCheckbox.checked = true;
}
updateDisabledState();
var placementKeys = [
"controllerLocation",
"controllerMarginTop",
"controllerMarginBottom"
];
var hasPlacementOverride =
rule && placementKeys.some(function (k) { return rule[k] !== undefined; });
ruleEl.querySelector(".override-placement").checked = Boolean(hasPlacementOverride);
syncSiteRuleField(ruleEl, rule, "controllerLocation", false);
syncSiteRuleField(ruleEl, rule, "controllerMarginTop", false);
syncSiteRuleField(ruleEl, rule, "controllerMarginBottom", false);
applySiteRuleOverrideState(ruleEl, "override-placement", "site-placement-container");
ruleEl.querySelector(".override-visibility").checked = Boolean(
rule && rule.startHidden !== undefined
);
syncSiteRuleField(ruleEl, rule, "startHidden", true);
applySiteRuleOverrideState(ruleEl, "override-visibility", "site-visibility-container");
var hasAutohideOverride = Boolean(
rule &&
(rule.hideWithControls !== undefined ||
rule.hideWithControlsTimer !== undefined)
);
ruleEl.querySelector(".override-autohide").checked = hasAutohideOverride;
syncSiteRuleField(ruleEl, rule, "hideWithControls", true);
syncSiteRuleField(ruleEl, rule, "hideWithControlsTimer", false);
applySiteRuleOverrideState(ruleEl, "override-autohide", "site-autohide-container");
var hasPlaybackOverride = Boolean(
rule &&
(rule.rememberSpeed !== undefined ||
rule.forceLastSavedSpeed !== undefined ||
rule.audioBoolean !== undefined)
);
ruleEl.querySelector(".override-playback").checked = hasPlaybackOverride;
syncSiteRuleField(ruleEl, rule, "rememberSpeed", true);
syncSiteRuleField(ruleEl, rule, "forceLastSavedSpeed", true);
syncSiteRuleField(ruleEl, rule, "audioBoolean", true);
applySiteRuleOverrideState(ruleEl, "override-playback", "site-playback-container");
ruleEl.querySelector(".override-opacity").checked = Boolean(
rule && rule.controllerOpacity !== undefined
);
syncSiteRuleField(ruleEl, rule, "controllerOpacity", false);
applySiteRuleOverrideState(ruleEl, "override-opacity", "site-opacity-container");
var hasSubtitleNudgeOverride = Boolean(
rule &&
(rule.enableSubtitleNudge !== undefined ||
rule.subtitleNudgeInterval !== undefined)
);
ruleEl.querySelector(".override-subtitleNudge").checked = hasSubtitleNudgeOverride;
syncSiteRuleField(ruleEl, rule, "enableSubtitleNudge", true);
syncSiteRuleField(ruleEl, rule, "subtitleNudgeInterval", false);
applySiteRuleOverrideState(
ruleEl,
"override-subtitleNudge",
"site-subtitleNudge-container"
);
var hasControlbarOverride = Boolean(rule && Array.isArray(rule.controllerButtons));
ruleEl.querySelector(".override-controlbar").checked = hasControlbarOverride;
populateControlBarZones(
ruleEl.querySelector(".site-cb-active"),
ruleEl.querySelector(".site-cb-available"),
hasControlbarOverride ? rule.controllerButtons : getControlBarOrder()
);
applySiteRuleOverrideState(ruleEl, "override-controlbar", "site-controlbar-container");
var hasPopupControlbarOverride = Boolean(
rule &&
(rule.showPopupControlBar !== undefined ||
Array.isArray(rule.popupControllerButtons))
);
ruleEl.querySelector(".override-popup-controlbar").checked =
hasPopupControlbarOverride;
populateControlBarZones(
ruleEl.querySelector(".site-popup-cb-active"),
ruleEl.querySelector(".site-popup-cb-available"),
hasPopupControlbarOverride && Array.isArray(rule.popupControllerButtons)
? sanitizePopupButtonOrder(rule.popupControllerButtons)
: getPopupControlBarOrder(),
function (id) {
return !popupExcludedButtonIds.has(id);
}
);
syncSiteRuleField(ruleEl, rule, "showPopupControlBar", true);
applySiteRuleOverrideState(
ruleEl,
"override-popup-controlbar",
"site-popup-controlbar-container"
);
var hasShortcutOverride = Boolean(
rule && Array.isArray(rule.shortcuts) && rule.shortcuts.length > 0
);
ruleEl.querySelector(".override-shortcuts").checked = hasShortcutOverride;
var rowsEl = ruleEl.querySelector(".site-shortcuts-rows");
if (hasShortcutOverride) {
rule.shortcuts.forEach((shortcut) => {
addSiteRuleShortcut(
rowsEl,
shortcut.action,
shortcut,
shortcut.value,
shortcut.force
);
});
}
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
refreshSiteRuleAddShortcutSelector(ruleEl);
document.getElementById("siteRulesContainer").appendChild(ruleEl);
}
function createControlBarBlock(buttonId) {
var def = controllerButtonDefs[buttonId];
if (!def) return null;
var block = document.createElement("div");
block.className = "cb-block";
block.dataset.buttonId = buttonId;
block.draggable = true;
var grip = document.createElement("span");
grip.className = "cb-grip";
var icon = document.createElement("span");
icon.className = "cb-icon";
fillControlBarIconElement(icon, buttonId);
var label = document.createElement("span");
label.className = "cb-label";
label.textContent = def.name;
block.appendChild(grip);
block.appendChild(icon);
block.appendChild(label);
return block;
}
function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) {
vscClearElement(activeZone);
vscClearElement(availableZone);
var allowed = function (id) {
if (!controllerButtonDefs[id]) return false;
return typeof allowButtonId === "function" ? Boolean(allowButtonId(id)) : true;
};
activeIds.forEach(function (id) {
if (!allowed(id)) return;
var block = createControlBarBlock(id);
if (block) activeZone.appendChild(block);
});
Object.keys(controllerButtonDefs).forEach(function (id) {
if (!allowed(id)) return;
if (!activeIds.includes(id)) {
var block = createControlBarBlock(id);
if (block) availableZone.appendChild(block);
}
});
}
function readControlBarOrder(activeZone) {
var blocks = activeZone.querySelectorAll(".cb-block");
return Array.from(blocks).map(function (block) {
return block.dataset.buttonId;
});
}
function populateControlBarEditor(activeIds) {
populateControlBarZones(
document.getElementById("controlBarActive"),
document.getElementById("controlBarAvailable"),
activeIds
);
}
function getControlBarOrder() {
return readControlBarOrder(document.getElementById("controlBarActive"));
}
function populatePopupControlBarEditor(activeIds) {
var popupActiveIds = sanitizePopupButtonOrder(activeIds);
populateControlBarZones(
document.getElementById("popupControlBarActive"),
document.getElementById("popupControlBarAvailable"),
popupActiveIds,
function (id) {
return !popupExcludedButtonIds.has(id);
}
);
}
function getPopupControlBarOrder() {
return sanitizePopupButtonOrder(
readControlBarOrder(document.getElementById("popupControlBarActive"))
);
}
function updatePopupEditorDisabledState() {
var checkbox = document.getElementById("popupMatchHoverControls");
var wrap = document.getElementById("popupCbEditorWrap");
if (!checkbox || !wrap) return;
if (checkbox.checked) {
wrap.classList.add("cb-editor-disabled");
} else {
wrap.classList.remove("cb-editor-disabled");
}
}
function getDragAfterElement(container, x, y) {
var elements = Array.from(
container.querySelectorAll(".cb-block:not(.cb-dragging)")
);
for (var i = 0; i < elements.length; i++) {
var box = elements[i].getBoundingClientRect();
var centerX = box.left + box.width / 2;
var centerY = box.top + box.height / 2;
var rowThresh = box.height * 0.5;
if (y - centerY > rowThresh) continue;
if (centerY - y > rowThresh) return elements[i];
if (x < centerX) return elements[i];
}
return undefined;
}
function initControlBarEditor() {
var draggedBlock = null;
function clearControlBarDropTargets(activeZone) {
document.querySelectorAll(".cb-dropzone.cb-over").forEach(function (zone) {
if (zone !== activeZone) {
zone.classList.remove("cb-over");
}
});
}
document.addEventListener("dragstart", function (e) {
var block = e.target.closest(".cb-block");
if (!block) return;
draggedBlock = block;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", block.dataset.buttonId);
requestAnimationFrame(function () {
block.classList.add("cb-dragging");
});
});
document.addEventListener("dragend", function (e) {
var block = e.target.closest(".cb-block");
if (!block) return;
block.classList.remove("cb-dragging");
draggedBlock = null;
clearControlBarDropTargets(null);
});
document.addEventListener("dragover", function (e) {
var zone = e.target.closest(".cb-dropzone");
if (!zone) {
clearControlBarDropTargets(null);
return;
}
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "move";
}
clearControlBarDropTargets(zone);
zone.classList.add("cb-over");
if (!draggedBlock) return;
var afterEl = getDragAfterElement(zone, e.clientX, e.clientY);
if (afterEl) {
zone.insertBefore(draggedBlock, afterEl);
} else {
zone.appendChild(draggedBlock);
}
});
document.addEventListener("drop", function (e) {
var zone = e.target.closest(".cb-dropzone");
if (zone) {
e.preventDefault();
}
clearControlBarDropTargets(null);
});
}
var lucidePickerSelectedSlug = null;
var lucideSearchTimer = null;
function setLucideStatus(msg) {
var el = document.getElementById("lucideIconStatus");
if (el) el.textContent = msg || "";
}
function repaintAllCbIconsFromCustomMap() {
document.querySelectorAll(".cb-block .cb-icon").forEach(function (icon) {
var block = icon.closest(".cb-block");
if (!block) return;
fillControlBarIconElement(icon, block.dataset.buttonId);
});
}
function persistCustomButtonIcons(map, callback) {
chrome.storage.local.set({ customButtonIcons: map }, function () {
if (chrome.runtime.lastError) {
setLucideStatus(
"Could not save icons: " + chrome.runtime.lastError.message
);
return;
}
customButtonIconsLive = map;
if (callback) callback();
repaintAllCbIconsFromCustomMap();
});
}
function initLucideButtonIconsUI() {
var actionSel = document.getElementById("lucideIconActionSelect");
var searchInput = document.getElementById("lucideIconSearch");
var resultsEl = document.getElementById("lucideIconResults");
var previewEl = document.getElementById("lucideIconPreview");
if (!actionSel || !searchInput || !resultsEl || !previewEl) return;
if (typeof getLucideTagsMap !== "function") return;
if (!actionSel.dataset.lucideInit) {
actionSel.dataset.lucideInit = "1";
vscClearElement(actionSel);
Object.keys(controllerButtonDefs).forEach(function (aid) {
if (aid === "nudge") {
Object.keys(lucideSubtitleNudgeActionLabels).forEach(function (subId) {
var o2 = document.createElement("option");
o2.value = subId;
o2.textContent =
lucideSubtitleNudgeActionLabels[subId] + " (" + subId + ")";
actionSel.appendChild(o2);
});
return;
}
var o = document.createElement("option");
o.value = aid;
o.textContent =
controllerButtonDefs[aid].name + " (" + aid + ")";
actionSel.appendChild(o);
});
}
function renderResults(slugs) {
vscClearElement(resultsEl);
slugs.forEach(function (slug) {
var b = document.createElement("button");
b.type = "button";
b.className = "lucide-result-tile";
b.dataset.slug = slug;
b.title = slug;
b.setAttribute("aria-label", slug);
if (slug === lucidePickerSelectedSlug) {
b.classList.add("lucide-picked");
}
var url =
typeof lucideIconSvgUrl === "function" ? lucideIconSvgUrl(slug) : "";
if (url) {
var img = document.createElement("img");
img.className = "lucide-result-thumb";
img.src = url;
img.alt = "";
img.loading = "lazy";
b.appendChild(img);
} else {
b.textContent = slug.slice(0, 3);
}
b.addEventListener("click", function () {
lucidePickerSelectedSlug = slug;
Array.prototype.forEach.call(
resultsEl.querySelectorAll("button"),
function (x) {
x.classList.toggle("lucide-picked", x.dataset.slug === slug);
}
);
fetchLucideSvg(slug)
.then(function (txt) {
var safe = sanitizeLucideSvg(txt);
if (!safe) throw new Error("Bad SVG");
if (!vscSetSvgContent(previewEl, safe)) {
throw new Error("Preview render failed");
}
setLucideStatus("Preview: " + slug);
})
.catch(function (e) {
vscClearElement(previewEl);
setLucideStatus(
"Could not load: " + slug + " — " + e.message
);
});
});
resultsEl.appendChild(b);
});
}
if (!searchInput.dataset.lucideBound) {
searchInput.dataset.lucideBound = "1";
searchInput.addEventListener("input", function () {
clearTimeout(lucideSearchTimer);
lucideSearchTimer = setTimeout(function () {
getLucideTagsMap(chrome.storage.local, false)
.then(function (map) {
var q = searchInput.value;
if (!q.trim()) {
vscClearElement(resultsEl);
return;
}
renderResults(searchLucideSlugs(map, q, 48));
})
.catch(function (e) {
setLucideStatus("Icon list error: " + e.message);
});
}, 200);
});
}
var applyBtn = document.getElementById("lucideIconApply");
if (applyBtn && !applyBtn.dataset.lucideBound) {
applyBtn.dataset.lucideBound = "1";
applyBtn.addEventListener("click", function () {
var action = actionSel.value;
var slug = lucidePickerSelectedSlug;
if (!action || !slug) {
setLucideStatus("Pick an action and click an icon first.");
return;
}
fetchLucideSvg(slug)
.then(function (txt) {
var safe = sanitizeLucideSvg(txt);
if (!safe) throw new Error("Sanitize failed");
var next = Object.assign({}, customButtonIconsLive);
next[action] = { slug: slug, svg: safe };
persistCustomButtonIcons(next, function () {
setLucideStatus(
"Saved " +
slug +
" for " +
action +
". Reload pages for the hover bar."
);
});
})
.catch(function (e) {
setLucideStatus("Apply failed: " + e.message);
});
});
}
var clrOne = document.getElementById("lucideIconClearAction");
if (clrOne && !clrOne.dataset.lucideBound) {
clrOne.dataset.lucideBound = "1";
clrOne.addEventListener("click", function () {
var action = actionSel.value;
if (!action) return;
var next = Object.assign({}, customButtonIconsLive);
delete next[action];
persistCustomButtonIcons(next, function () {
setLucideStatus("Cleared custom icon for " + action + ".");
});
});
}
var clrAll = document.getElementById("lucideIconClearAll");
if (clrAll && !clrAll.dataset.lucideBound) {
clrAll.dataset.lucideBound = "1";
clrAll.addEventListener("click", function () {
persistCustomButtonIcons({}, function () {
setLucideStatus("All custom icons cleared.");
});
});
}
var reloadTags = document.getElementById("lucideIconReloadTags");
if (reloadTags && !reloadTags.dataset.lucideBound) {
reloadTags.dataset.lucideBound = "1";
reloadTags.addEventListener("click", function () {
getLucideTagsMap(chrome.storage.local, true)
.then(function () {
setLucideStatus("Icon name list refreshed.");
})
.catch(function (e) {
setLucideStatus("Refresh failed: " + e.message);
});
});
}
}
function restore_options() {
chrome.storage.sync.get(tcDefaults, function (storage) {
chrome.storage.local.get(["customButtonIcons"], function (loc) {
customButtonIconsLive =
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
document.getElementById("rememberSpeed").checked = storage.rememberSpeed;
document.getElementById("forceLastSavedSpeed").checked =
storage.forceLastSavedSpeed;
document.getElementById("audioBoolean").checked = storage.audioBoolean;
document.getElementById("enabled").checked = storage.enabled;
document.getElementById("startHidden").checked = storage.startHidden;
// Migration/Normalization for hideWithControls
const hideWithControls = typeof storage.hideWithControls !== "undefined"
? storage.hideWithControls
: storage.hideWithYouTubeControls;
document.getElementById("hideWithControls").checked = hideWithControls;
document.getElementById("hideWithControlsTimer").value =
storage.hideWithControlsTimer || tcDefaults.hideWithControlsTimer;
document.getElementById("controllerLocation").value =
normalizeControllerLocation(storage.controllerLocation);
document.getElementById("controllerOpacity").value =
storage.controllerOpacity;
document.getElementById("blacklist").value = storage.blacklist;
// ADDED: Restore nudge settings
document.getElementById("controllerMarginTop").value =
storage.controllerMarginTop ?? tcDefaults.controllerMarginTop;
document.getElementById("controllerMarginBottom").value =
storage.controllerMarginBottom ?? tcDefaults.controllerMarginBottom;
document.getElementById("showPopupControlBar").checked =
storage.showPopupControlBar !== false;
document.getElementById("enableSubtitleNudge").checked =
storage.enableSubtitleNudge;
document.getElementById("subtitleNudgeInterval").value =
storage.subtitleNudgeInterval;
document.getElementById("subtitleNudgeAmount").value =
storage.subtitleNudgeAmount;
// Original key binding restoration logic
if (
!Array.isArray(storage.keyBindings) ||
storage.keyBindings.length === 0
) {
// If keyBindings missing or not an array, use defaults from tcDefaults
storage.keyBindings = tcDefaults.keyBindings;
if (!Array.isArray(storage.keyBindings) || storage.keyBindings.length === 0) {
storage.keyBindings = tcDefaults.keyBindings.slice();
}
if (storage.keyBindings.filter((x) => x.action == "display").length == 0) {
storage.keyBindings.push({
action: "display",
value: 0,
force: false,
predefined: true,
key: storage.displayKeyCode || tcDefaults.displayKeyCode
ensureAllDefaultBindings(storage);
document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove());
storage.keyBindings.forEach((item) => {
var row = document.getElementById(item.action);
var normalizedBinding = normalizeStoredBinding(item);
if (!row) {
add_shortcut(item.action, item.value);
row = document.querySelector(".shortcut-row.customs:last-of-type");
}
if (!row) return;
var keyInput = row.querySelector(".customKey");
if (keyInput) {
updateCustomShortcutInputText(keyInput, normalizedBinding || null);
}
var valueInput = row.querySelector(".customValue");
if (customActionsNoValues.includes(item.action)) {
if (valueInput) {
valueInput.value = "N/A";
valueInput.disabled = true;
}
} else if (valueInput) {
valueInput.value = formatSpeedBindingDisplay(item.action, item.value);
}
});
refreshAddShortcutSelector();
// Load site rules (use defaults if none in storage or empty array)
var siteRules =
Array.isArray(storage.siteRules) && storage.siteRules.length > 0
? storage.siteRules
: tcDefaults.siteRules || [];
vscClearElement(document.getElementById("siteRulesContainer"));
if (siteRules.length > 0) {
siteRules.forEach((rule) => {
if (rule && rule.pattern) {
createSiteRule(rule);
}
});
}
// Clear existing dynamic shortcuts before restoring (if any were added by mistake)
const dynamicShortcuts = document.querySelectorAll(".customs:not([id])");
dynamicShortcuts.forEach((sc) => sc.remove());
var controllerButtons = Array.isArray(storage.controllerButtons)
? storage.controllerButtons
: tcDefaults.controllerButtons;
populateControlBarEditor(controllerButtons);
for (let i in storage.keyBindings) {
var item = storage.keyBindings[i];
if (item.predefined) {
if (item["action"] == "display" && typeof item["key"] === "undefined") {
item["key"] = storage.displayKeyCode || tcDefaults.displayKeyCode;
}
if (customActionsNoValues.includes(item["action"])) {
const el = document.querySelector(
"#" + item["action"] + " .customValue"
);
if (el) el.disabled = true;
}
const keyEl = document.querySelector(
"#" + item["action"] + " .customKey"
);
const valEl = document.querySelector(
"#" + item["action"] + " .customValue"
);
const forceEl = document.querySelector(
"#" + item["action"] + " .customForce"
);
if (keyEl) updateCustomShortcutInputText(keyEl, item["key"]);
if (valEl) valEl.value = item["value"];
if (forceEl) forceEl.value = String(item["force"]); // Ensure string for select value
} else {
// Non-predefined, dynamically added shortcuts
add_shortcut();
const dom = document.querySelector(".customs:last-of-type"); // Gets the newly added one
dom.querySelector(".customDo").value = item["action"];
if (customActionsNoValues.includes(item["action"])) {
dom.querySelector(".customValue").disabled = true;
}
updateCustomShortcutInputText(
dom.querySelector(".customKey"),
item["key"]
);
dom.querySelector(".customValue").value = item["value"];
dom.querySelector(".customForce").value = String(item["force"]);
}
}
document.getElementById("popupMatchHoverControls").checked =
storage.popupMatchHoverControls !== false;
var popupButtons = Array.isArray(storage.popupControllerButtons)
? storage.popupControllerButtons
: tcDefaults.popupControllerButtons;
populatePopupControlBarEditor(popupButtons);
updatePopupEditorDisabledState();
initLucideButtonIconsUI();
});
});
}
function restore_defaults() {
/* ... same as your original, tcDefaults now includes nudge defaults ... */
// Remove all dynamically added shortcuts first
document.querySelectorAll(".customs:not([id])").forEach((el) => el.remove());
// Then set defaults and restore options, which will re-add predefined ones correctly
chrome.storage.local.remove(
["customButtonIcons", "lucideTagsCacheV1", "lucideTagsCacheV1At"],
function () {}
);
chrome.storage.sync.set(tcDefaults, function () {
restore_options(); // This will populate based on tcDefaults
restore_options();
var status = document.getElementById("status");
status.textContent = "Default options restored";
setTimeout(function () {
@@ -337,30 +1742,46 @@ function restore_defaults() {
});
}
function show_experimental() {
/* ... same as your original ... */
document
.querySelectorAll(".customForce")
.forEach((item) => (item.style.display = "inline-block"));
}
document.addEventListener("DOMContentLoaded", function () {
/* ... same as your original event listeners setup ... */
var manifest = chrome.runtime.getManifest();
var versionElement = document.getElementById("app-version");
if (versionElement) {
versionElement.textContent = manifest.version;
}
restore_options();
initControlBarEditor();
document.getElementById("popupMatchHoverControls")
.addEventListener("change", updatePopupEditorDisabledState);
document.getElementById("save").addEventListener("click", save_options);
document.getElementById("add").addEventListener("click", add_shortcut);
const addSelector = document.getElementById("addShortcutSelector");
if (addSelector) {
addSelector.addEventListener("change", function (e) {
if (e.target.value) {
add_shortcut(e.target.value);
e.target.value = ""; // Reset selector
}
});
}
document
.getElementById("restore")
.addEventListener("click", restore_defaults);
document
.getElementById("experimental")
.addEventListener("click", show_experimental);
.getElementById("addSiteRule")
.addEventListener("click", function () {
createSiteRule(null);
});
function eventCaller(event, className, funcName) {
if (!event.target.classList || !event.target.classList.contains(className))
if (!event.target.classList || !event.target.classList.contains(className)) {
return;
}
funcName(event);
}
document.addEventListener("keypress", (event) =>
eventCaller(event, "customValue", inputFilterNumbersOnly)
);
@@ -373,19 +1794,92 @@ document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("keydown", (event) =>
eventCaller(event, "customKey", recordKeyPress)
);
document.addEventListener("click", (event) =>
eventCaller(event, "removeParent", function () {
event.target.parentNode.remove();
})
);
document.addEventListener("change", (event) => {
eventCaller(event, "customDo", function () {
if (customActionsNoValues.includes(event.target.value)) {
event.target.nextElementSibling.nextElementSibling.disabled = true;
event.target.nextElementSibling.nextElementSibling.value = 0; // Or "" if placeholder is preferred
} else {
event.target.nextElementSibling.nextElementSibling.disabled = false;
document.addEventListener("click", (event) => {
var target = event.target;
var targetEl = target && target.closest ? target : target.parentElement;
if (!targetEl) return;
var removeParentButton = targetEl.closest(".removeParent");
if (removeParentButton) {
var removedRow = removeParentButton.parentNode;
var siteRuleForShortcut = removedRow.closest(".site-rule");
removedRow.remove();
refreshAddShortcutSelector();
if (siteRuleForShortcut) {
refreshSiteRuleAddShortcutSelector(siteRuleForShortcut);
}
});
return;
}
var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
if (removeSiteRuleButton) {
removeSiteRuleButton.closest(".site-rule").remove();
return;
}
var toggleButton = targetEl.closest(".toggle-site-rule");
if (toggleButton) {
var ruleEl = toggleButton.closest(".site-rule");
var isCollapsed = ruleEl.classList.contains("collapsed");
setSiteRuleExpandedState(ruleEl, isCollapsed);
return;
}
});
document.addEventListener("change", (event) => {
if (event.target.classList.contains("customDo")) {
var valueInput = event.target.nextElementSibling.nextElementSibling;
if (customActionsNoValues.includes(event.target.value)) {
valueInput.disabled = true;
valueInput.value = 0;
} else {
valueInput.disabled = false;
}
}
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",
"override-visibility": "site-visibility-container",
"override-autohide": "site-autohide-container",
"override-playback": "site-playback-container",
"override-opacity": "site-opacity-container",
"override-subtitleNudge": "site-subtitleNudge-container",
"override-controlbar": "site-controlbar-container",
"override-popup-controlbar": "site-popup-controlbar-container",
"override-shortcuts": "site-shortcuts-container"
};
for (var ocb in siteOverrideContainers) {
if (event.target.classList.contains(ocb)) {
var siteRuleRoot = event.target.closest(".site-rule");
var targetBox = siteRuleRoot.querySelector(
"." + siteOverrideContainers[ocb]
);
if (targetBox) {
setSiteOverrideContainerState(targetBox, event.target.checked);
}
if (ocb === "override-shortcuts") {
refreshSiteRuleAddShortcutSelector(siteRuleRoot);
}
return;
}
}
});
});
+2128
View File
@@ -0,0 +1,2128 @@
{
"name": "speeder",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "speeder",
"devDependencies": {
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nwsapi": {
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.1",
"@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "speeder",
"private": true,
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
}
}
+305 -19
View File
@@ -1,34 +1,320 @@
body {
min-width: 8em;
:root {
--bg: #f4f5f7;
--panel: #ffffff;
--border: #e2e5e9;
--border-strong: #d4d9e0;
--text: #17191c;
--muted: #626b76;
--accent: #111827;
}
hr {
width: 100%;
border: 0;
height: 0;
border-top: 1px solid rgba(0, 0, 0, 0.3);
margin: 0.6em 0;
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-width: 220px;
background: var(--bg);
color: var(--text);
font: 13px/1.45 "Avenir Next", "SF Pro Text", "Segoe UI", sans-serif;
padding: 12px;
}
.popup-shell {
display: flex;
flex-direction: column;
gap: 10px;
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.popup-title {
font-weight: 600;
font-size: 15px;
font-family: "Avenir Next", "SF Pro Display", "Segoe UI", sans-serif;
letter-spacing: -0.01em;
}
.popup-version {
font-size: 11px;
font-weight: 600;
color: var(--muted);
padding: 2px 7px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel);
}
.popup-actions {
display: flex;
flex-direction: column;
gap: 6px;
}
button {
appearance: none;
width: 100%;
background-image: linear-gradient(#ededed, #ededed 38%, #dedede);
border: 1px solid rgba(0, 0, 0, 0.25);
border-radius: 2px;
outline: none;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08),
inset 0 1px 2px rgba(255, 255, 255, 0.75);
color: #444;
text-shadow: 0 1px 0 rgb(240, 240, 240);
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--panel);
color: var(--text);
font: inherit;
font-weight: 500;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
button:hover {
background: #f8f9fb;
border-color: #c5ccd5;
}
button:active {
background: #f1f3f5;
}
button:focus-visible {
outline: 2px solid rgba(17, 24, 39, 0.14);
outline-offset: 2px;
}
#refresh {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
#refresh:hover {
background: #1f2937;
border-color: #1f2937;
}
.popup-divider {
height: 1px;
background: var(--border);
margin: 2px 0;
}
.popup-control-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 10px;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 8px;
}
.popup-speed {
font-family: "Lucida Console", Monaco, monospace;
font-size: 13px;
font-weight: bold;
color: var(--text);
margin-right: 4px;
line-height: 1;
user-select: none;
white-space: nowrap;
}
.popup-control-bar button {
width: auto;
min-height: 24px;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-family: "Lucida Console", Monaco, monospace;
font-size: 13px;
line-height: 13px;
font-weight: bold;
padding: 3px 7px;
cursor: pointer;
}
.popup-control-bar button:hover {
background: var(--panel);
border-color: var(--border-strong);
}
.popup-control-bar button:active {
background: var(--bg);
}
.popup-control-bar button.rw {
opacity: 0.55;
}
.popup-control-bar button.hideButton {
opacity: 0.55;
}
.popup-control-bar button .vsc-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
line-height: 0;
vertical-align: middle;
}
.popup-control-bar button .vsc-btn-icon svg {
width: 100%;
height: 100%;
flex-shrink: 0;
}
.popup-status {
font-size: 12px;
color: var(--muted);
font-weight: 500;
text-align: center;
padding: 2px 0;
}
.popup-links {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.popup-secondary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.secondary {
font-size: 0.95em;
margin: 0.15em 0;
font-size: 12px;
min-height: 28px;
color: var(--muted);
}
.donate-wrap {
display: grid;
grid-template-columns: 1fr;
}
.donate-split {
display: grid;
grid-template-columns: repeat(3, 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;
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;
}
.donate-icon-btn:last-child {
border-radius: 0 8px 8px 0;
}
.hide {
display: none;
display: none !important;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111315;
--panel: #171a1d;
--border: #2b3138;
--border-strong: #3a414a;
--text: #f2f4f6;
--muted: #a0a8b2;
--accent: #f2f4f6;
}
body {
color-scheme: dark;
}
button:hover {
background: #1f2226;
border-color: #4a515a;
}
button:active {
background: #252a2f;
}
#refresh {
background: #f2f4f6;
border-color: #f2f4f6;
color: #111315;
}
#refresh:hover {
background: #dfe3e8;
border-color: #dfe3e8;
}
.donate-icon-btn:hover {
background: #1f2226;
border-color: #4a515a;
}
.donate-icon-btn:active {
background: #252a2f;
}
}
+96 -10
View File
@@ -1,18 +1,104 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<title>Video Speed Controller: Popup</title>
<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>
<button id="enable" class="hide">Enable</button>
<button id="disable">Disable</button>
<span id="status" class="hide"></span>
<hr />
<button id="config">Settings</button>
<hr />
<button id="feedback" class="secondary">Send feedback</button>
<button id="about" class="secondary">About</button>
<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">
<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="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>
+330 -16
View File
@@ -1,14 +1,233 @@
document.addEventListener("DOMContentLoaded", function () {
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var siteRuleUtils = speederShared.siteRules || {};
var popupControlUtils = speederShared.popupControls || {};
/* `label` is only used if ui-icons.js has no path for this action (fallback). */
var controllerButtonDefs = {
rewind: { label: "", className: "rw" },
slower: { label: "", className: "" },
faster: { label: "", className: "" },
advance: { label: "", className: "rw" },
display: { label: "", className: "hideButton" },
reset: { label: "\u21BB", className: "" },
fast: { label: "", className: "" },
nudge: { label: "", className: "" },
pause: { label: "", className: "" },
muted: { label: "", className: "" },
louder: { label: "", className: "" },
softer: { label: "", className: "" },
mark: { label: "", className: "" },
jump: { label: "", className: "" },
settings: { label: "", className: "" }
};
var defaultButtons = ["rewind", "slower", "faster", "advance", "display"];
var popupExcludedButtonIds = new Set(["settings"]);
var storageDefaults = {
enabled: true,
showPopupControlBar: true,
controllerButtons: defaultButtons,
popupMatchHoverControls: true,
popupControllerButtons: defaultButtons,
siteRules: []
};
var renderToken = 0;
function matchSiteRule(url, siteRules) {
return siteRuleUtils.matchSiteRule(url, siteRules);
}
function isSiteRuleDisabled(rule) {
return siteRuleUtils.isSiteRuleDisabled(rule);
}
function resolvePopupButtons(storage, siteRule) {
return popupControlUtils.resolvePopupButtons(storage, siteRule, {
controllerButtonDefs: controllerButtonDefs,
defaultButtons: defaultButtons,
excludedIds: popupExcludedButtonIds
});
}
function setControlBarVisible(visible) {
var bar = document.getElementById("popupControlBar");
var dividers = document.querySelectorAll(".popup-divider");
if (bar) bar.style.display = visible ? "" : "none";
dividers.forEach(function (d) { d.style.display = visible ? "" : "none"; });
}
function sendToActiveTab(message, callback) {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (tabs[0] && tabs[0].id) {
chrome.tabs.sendMessage(tabs[0].id, message, function (response) {
if (chrome.runtime.lastError) {
if (callback) callback(null);
} else {
if (callback) callback(response);
}
});
} else {
if (callback) callback(null);
}
});
}
function getActiveTabContext(callback) {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
var activeTab = tabs && tabs[0] ? tabs[0] : null;
if (!activeTab || !activeTab.id) {
if (callback) callback({ tab: null, url: "" });
return;
}
var tabUrl = typeof activeTab.url === "string" ? activeTab.url : "";
if (tabUrl.length > 0) {
if (callback) callback({ tab: activeTab, url: tabUrl });
return;
}
chrome.tabs.sendMessage(
activeTab.id,
{ action: "get_page_context" },
function (response) {
if (chrome.runtime.lastError) {
if (callback) callback({ tab: activeTab, url: "" });
return;
}
var pageUrl =
response && typeof response.url === "string" ? response.url : "";
if (callback) callback({ tab: activeTab, url: pageUrl });
}
);
});
}
function updateSpeedDisplay(speed) {
var el = document.getElementById("popupSpeed");
if (el) el.textContent = (speed != null ? Number(speed) : 1).toFixed(2);
}
function applySpeedAndResetFromResponse(response) {
if (response && response.speed != null) {
updateSpeedDisplay(response.speed);
}
}
function pickBestFrameSpeedResult(results) {
return popupControlUtils.pickBestFrameSpeedResult(results);
}
function querySpeed() {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (!tabs[0] || tabs[0].id == null) {
return;
}
var tabId = tabs[0].id;
chrome.tabs.executeScript(
tabId,
{ allFrames: true, file: "frameSpeedSnapshot.js" },
function (results) {
if (chrome.runtime.lastError) {
sendToActiveTab({ action: "get_speed" }, function (response) {
applySpeedAndResetFromResponse(response || { speed: 1 });
});
return;
}
var best = pickBestFrameSpeedResult(results);
if (best) {
applySpeedAndResetFromResponse(best);
} else {
sendToActiveTab({ action: "get_speed" }, function (response) {
applySpeedAndResetFromResponse(response || { speed: 1 });
});
}
}
);
});
}
function buildControlBar(buttons, customIconsMap) {
var bar = document.getElementById("popupControlBar");
if (!bar) return;
var existing = bar.querySelectorAll("button");
existing.forEach(function (btn) { btn.remove(); });
var customMap = customIconsMap || {};
buttons.forEach(function (btnId) {
var def = controllerButtonDefs[btnId];
if (!def) return;
var btn = document.createElement("button");
btn.dataset.action = btnId;
var customEntry = customMap[btnId];
if (customEntry && customEntry.svg) {
var customSpan = vscCreateSvgWrap(document, customEntry.svg, "vsc-btn-icon");
if (customSpan) {
btn.appendChild(customSpan);
} else {
btn.textContent = def.label || "?";
}
} else if (typeof vscIconSvgString === "function") {
var svgStr = vscIconSvgString(btnId, 16);
if (svgStr) {
var iconSpan = vscCreateSvgWrap(document, svgStr, "vsc-btn-icon");
if (iconSpan) {
btn.appendChild(iconSpan);
} else {
btn.textContent = def.label || "?";
}
} else {
btn.textContent = def.label || "?";
}
} else {
btn.textContent = def.label || "?";
}
if (def.className) btn.className = def.className;
btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1);
btn.addEventListener("click", function () {
if (btnId === "settings") {
window.open(chrome.runtime.getURL("options.html"));
return;
}
sendToActiveTab(
{ action: "run_action", actionName: btnId },
function () {
querySpeed();
}
);
});
bar.appendChild(btn);
});
}
var manifest = chrome.runtime.getManifest();
var versionElement = document.querySelector("#app-version");
if (versionElement) {
versionElement.innerText = manifest.version;
}
document.querySelector("#config").addEventListener("click", function () {
window.open(chrome.runtime.getURL("options.html"));
});
document.querySelector("#about").addEventListener("click", function () {
window.open("https://github.com/codebicycle/videospeed");
window.open("https://github.com/SoPat712/Speeder");
});
document.querySelector("#feedback").addEventListener("click", function () {
window.open("https://github.com/codebicycle/videospeed/issues");
window.open("https://github.com/SoPat712/Speeder/issues");
});
document.querySelector("#donate").addEventListener("click", function () {
this.classList.add("hide");
document.querySelector("#donateOptions").classList.remove("hide");
});
document.querySelector("#enable").addEventListener("click", function () {
@@ -19,20 +238,109 @@ document.addEventListener("DOMContentLoaded", function () {
toggleEnabled(false, settingsSavedReloadMessage);
});
chrome.storage.sync.get({ enabled: true }, function (storage) {
toggleEnabledUI(storage.enabled);
document.querySelector("#refresh").addEventListener("click", function () {
setStatusMessage("Rescanning page...");
sendToActiveTab({ action: "rescan_page" }, function (response) {
if (!response) {
setStatusMessage("Cannot run on this page.");
} else if (response.status === "complete") {
setStatusMessage("Scan complete. Closing...");
setTimeout(function () { window.close(); }, 500);
} else {
setStatusMessage("Scan failed. Please reload the page.");
}
});
});
function renderForActiveTab() {
var currentRenderToken = ++renderToken;
chrome.storage.local.get(["customButtonIcons"], function (loc) {
if (currentRenderToken !== renderToken) return;
var customIconsMap =
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
chrome.storage.sync.get(storageDefaults, function (storage) {
if (currentRenderToken !== renderToken) return;
getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return;
var url = context && context.url ? context.url : "";
var siteRule = matchSiteRule(url, storage.siteRules);
var siteDisabled = isSiteRuleDisabled(siteRule);
var siteAvailable = siteRuleUtils.isSpeederActiveForSite(
storage.enabled,
siteRule
);
var showBar = storage.showPopupControlBar !== false;
if (siteRule && siteRule.showPopupControlBar !== undefined) {
showBar = siteRule.showPopupControlBar;
}
toggleEnabledUI(storage.enabled !== false);
buildControlBar(
resolvePopupButtons(storage, siteRule),
customIconsMap
);
setControlBarVisible(siteAvailable && showBar);
if (siteDisabled) {
setStatusMessage("Speeder is disabled for this site.");
updateSpeedDisplay(1);
return;
}
clearStatusMessage();
if (siteAvailable) {
querySpeed();
} else {
updateSpeedDisplay(1);
}
});
});
});
}
chrome.tabs.onActivated.addListener(function () {
renderForActiveTab();
});
chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) {
if (!tab || tab.active !== true) return;
if (changeInfo.url !== undefined || changeInfo.status === "complete") {
renderForActiveTab();
}
});
chrome.storage.onChanged.addListener(function (changes, areaName) {
if (areaName === "local" && changes.customButtonIcons) {
renderForActiveTab();
return;
}
if (areaName !== "sync") return;
if (
changes.enabled ||
changes.showPopupControlBar ||
changes.controllerButtons ||
changes.popupMatchHoverControls ||
changes.popupControllerButtons ||
changes.siteRules
) {
renderForActiveTab();
}
});
renderForActiveTab();
function toggleEnabled(enabled, callback) {
chrome.storage.sync.set(
{
enabled: enabled
},
function () {
toggleEnabledUI(enabled);
if (callback) callback(enabled);
}
);
chrome.storage.sync.set({ enabled: enabled }, function () {
toggleEnabledUI(enabled);
if (callback) callback(enabled);
});
}
function toggleEnabledUI(enabled) {
@@ -42,9 +350,9 @@ document.addEventListener("DOMContentLoaded", function () {
const suffix = `${enabled ? "" : "_disabled"}.png`;
chrome.browserAction.setIcon({
path: {
"19": "icons/icon19" + suffix,
"38": "icons/icon38" + suffix,
"48": "icons/icon48" + suffix
19: "icons/icon19" + suffix,
38: "icons/icon38" + suffix,
48: "icons/icon48" + suffix
}
});
}
@@ -60,4 +368,10 @@ document.addEventListener("DOMContentLoaded", function () {
status_element.classList.toggle("hide", false);
status_element.innerText = str;
}
function clearStatusMessage() {
const status_element = document.querySelector("#status");
status_element.classList.toggle("hide", true);
status_element.innerText = "";
}
});
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Squash beta onto main, set manifest version, one release commit, push stable tag (v* without -beta).
# Does not merge dev or push to beta — promote only what is already on beta.
# Triggers .github/workflows/deploy.yml: listed AMO submission.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
manifest_version() {
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
}
bump_manifest() {
local ver="$1"
VER="$ver" python3 <<'PY'
import json
import os
ver = os.environ["VER"]
path = "manifest.json"
with open(path, encoding="utf-8") as f:
data = json.load(f)
data["version"] = ver
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
}
normalize_semver() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
s="${s#v}"
s="${s#V}"
printf '%s' "$s"
}
validate_semver() {
local s="$1"
if [[ -z "$s" ]]; then
echo "Error: empty version." >&2
return 1
fi
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "Error: invalid version (use something like 5.0.4)." >&2
return 1
fi
}
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean. Commit or stash before releasing." >&2
exit 1
fi
git checkout beta
git pull origin beta
echo "Current version on beta (manifest.json): $(manifest_version)"
read -r -p "Release version for manifest.json + tag (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER"
TAG="v${SEMVER}"
if [[ "$TAG" == *-beta* ]]; then
echo "Warning: stable tags should not contain '-beta' (workflow would use unlisted + prerelease, not AMO listed)."
read -r -p "Continue anyway? [y/N] " w
[[ "${w:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
fi
echo
echo "This will:"
echo " 1. checkout main, merge --squash origin/beta (single release commit on main)"
echo " 2. set manifest.json to $SEMVER in that commit (if anything else changed, it is included too)"
echo " 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
[[ "${confirm:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
echo "🚀 Releasing stable $TAG to AMO (listed)"
git checkout main
git pull origin main
git merge --squash beta
bump_manifest "$SEMVER"
git add -A
git commit -m "Release $TAG"
git push origin main
git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"
git checkout dev
echo "✅ Done: main squashed from beta, tagged $TAG (manifest $SEMVER)"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Merge dev → beta, push beta, and push an annotated beta tag (v*-beta*).
# Triggers .github/workflows/deploy.yml: unlisted AMO sign + GitHub prerelease.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
manifest_version() {
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
}
bump_manifest() {
local ver="$1"
VER="$ver" python3 <<'PY'
import json
import os
ver = os.environ["VER"]
path = "manifest.json"
with open(path, encoding="utf-8") as f:
data = json.load(f)
data["version"] = ver
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
}
normalize_semver() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
s="${s#v}"
s="${s#V}"
printf '%s' "$s"
}
validate_semver() {
local s="$1"
if [[ -z "$s" ]]; then
echo "Error: empty version." >&2
return 1
fi
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "Error: invalid version (use something like 5.0.4 or 5.0.4-beta.1)." >&2
return 1
fi
}
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean. Commit or stash before releasing." >&2
exit 1
fi
git checkout dev
git pull origin dev
echo "Current version in manifest.json: $(manifest_version)"
read -r -p "New version for manifest.json (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER"
echo "Beta git tag will include '-beta' (required by deploy.yml)."
read -r -p "Beta tag suffix [beta.1]: " SUFFIX_IN
SUFFIX="${SUFFIX_IN#"${SUFFIX_IN%%[![:space:]]*}"}"
SUFFIX="${SUFFIX%"${SUFFIX##*[![:space:]]}"}"
SUFFIX="${SUFFIX:-beta.1}"
TAG="v${SEMVER}-${SUFFIX}"
if [[ "$TAG" != *-beta* ]]; then
echo "Error: beta tag must contain '-beta' for the workflow (got $TAG). Try suffix like beta.1." >&2
exit 1
fi
echo
echo "This will:"
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)"
read -r -p "Continue? [y/N] " confirm
[[ "${confirm:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
echo "🚀 Releasing beta $TAG"
bump_manifest "$SEMVER"
git add manifest.json
git commit -m "Bump version to $SEMVER"
git push origin dev
git checkout beta
git pull origin beta
git merge dev --no-ff -m "$TAG"
git push origin beta
git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"
git checkout dev
git pull origin dev
echo "✅ Done: beta $TAG (manifest $SEMVER; dev + beta + tag pushed)"
+644
View File
@@ -0,0 +1,644 @@
(function (global) {
"use strict";
var SITE_RULES_DIFF_FORMAT = "defaults-diff-v1";
var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"];
var SITE_RULE_OVERRIDE_KEYS = [
"controllerLocation",
"controllerMarginTop",
"controllerMarginBottom",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"controllerOpacity",
"enableSubtitleNudge",
"subtitleNudgeInterval",
"controllerButtons",
"showPopupControlBar",
"popupControllerButtons",
"shortcuts",
"preferredSpeed"
];
var DIFFABLE_OPTION_KEYS = [
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"enabled",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"controllerLocation",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginBottom",
"keyBindings",
"siteRules",
"siteRulesMeta",
"siteRulesFormat",
"controllerButtons",
"showPopupControlBar",
"popupMatchHoverControls",
"popupControllerButtons",
"enableSubtitleNudge",
"subtitleNudgeInterval",
"subtitleNudgeAmount"
];
var MANAGED_SYNC_KEYS = DIFFABLE_OPTION_KEYS.concat([
"hideWithYouTubeControls"
]);
var DEFAULT_SETTINGS = {
speed: 1.0,
lastSpeed: 1.0,
displayKeyCode: 86,
rememberSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
forceLastSavedSpeed: false,
enabled: true,
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
keyBindings: [
{
action: "display",
key: "V",
keyCode: 86,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "move",
key: "P",
keyCode: 80,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "slower",
key: "S",
keyCode: 83,
code: null,
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "faster",
key: "D",
keyCode: 68,
code: null,
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "rewind",
key: "Z",
keyCode: 90,
code: null,
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "advance",
key: "X",
keyCode: 88,
code: null,
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "reset",
key: "R",
keyCode: 82,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "fast",
key: "G",
keyCode: 71,
code: null,
disabled: false,
value: 1.8,
force: false,
predefined: true
},
{
action: "toggleSubtitleNudge",
key: "N",
keyCode: 78,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
}
],
siteRules: [
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
enabled: true,
enableSubtitleNudge: true,
subtitleNudgeInterval: 50
},
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
}
],
controllerButtons: DEFAULT_BUTTONS.slice(),
showPopupControlBar: true,
popupMatchHoverControls: true,
popupControllerButtons: DEFAULT_BUTTONS.slice(),
enableSubtitleNudge: false,
subtitleNudgeInterval: 50,
subtitleNudgeAmount: 0.001
};
function clonePlainData(value) {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value));
}
function hasOwn(obj, key) {
return Boolean(obj) && Object.prototype.hasOwnProperty.call(obj, key);
}
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function sortComparableValue(value) {
if (Array.isArray(value)) {
return value.map(sortComparableValue);
}
if (isPlainObject(value)) {
var sorted = {};
Object.keys(value)
.sort()
.forEach(function (key) {
if (value[key] === undefined) {
return;
}
sorted[key] = sortComparableValue(value[key]);
});
return sorted;
}
return value;
}
function areComparableValuesEqual(a, b) {
return (
JSON.stringify(sortComparableValue(a)) ===
JSON.stringify(sortComparableValue(b))
);
}
function deepMergeDefaults(defaults, overrides) {
if (Array.isArray(defaults)) {
return Array.isArray(overrides)
? clonePlainData(overrides)
: clonePlainData(defaults);
}
if (isPlainObject(defaults)) {
var result = clonePlainData(defaults) || {};
if (!isPlainObject(overrides)) {
return result;
}
Object.keys(overrides).forEach(function (key) {
if (overrides[key] === undefined) {
return;
}
if (hasOwn(defaults, key)) {
result[key] = deepMergeDefaults(defaults[key], overrides[key]);
} else {
result[key] = clonePlainData(overrides[key]);
}
});
return result;
}
return overrides === undefined
? clonePlainData(defaults)
: clonePlainData(overrides);
}
function deepDiff(current, defaults) {
if (current === undefined) {
return undefined;
}
if (Array.isArray(current)) {
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
if (isPlainObject(current)) {
var result = {};
Object.keys(current).forEach(function (key) {
var diff = deepDiff(current[key], defaults && defaults[key]);
if (diff !== undefined) {
result[key] = diff;
}
});
return Object.keys(result).length > 0 ? result : undefined;
}
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
function getDefaultSiteRules() {
return clonePlainData(DEFAULT_SETTINGS.siteRules) || [];
}
function getDefaultSiteRulesByPattern() {
var map = Object.create(null);
getDefaultSiteRules().forEach(function (rule) {
if (!rule || typeof rule.pattern !== "string" || !rule.pattern) {
return;
}
map[rule.pattern] = rule;
});
return map;
}
function normalizeSiteRuleForDiff(rule, baseSettings) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return null;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (!pattern) {
return null;
}
var normalized = { pattern: pattern };
var baseEnabled = hasOwn(baseSettings, "enabled")
? Boolean(baseSettings.enabled)
: true;
var ruleEnabled = hasOwn(rule, "enabled")
? Boolean(rule.enabled)
: hasOwn(rule, "disableExtension")
? !Boolean(rule.disableExtension)
: baseEnabled;
if (!areComparableValuesEqual(ruleEnabled, baseEnabled)) {
normalized.enabled = ruleEnabled;
}
SITE_RULE_OVERRIDE_KEYS.forEach(function (key) {
var baseValue = clonePlainData(baseSettings[key]);
var effectiveValue = hasOwn(rule, key)
? clonePlainData(rule[key])
: baseValue;
if (!areComparableValuesEqual(effectiveValue, baseValue)) {
normalized[key] = effectiveValue;
}
});
Object.keys(rule).forEach(function (key) {
if (
key === "pattern" ||
key === "enabled" ||
key === "disableExtension" ||
SITE_RULE_OVERRIDE_KEYS.indexOf(key) !== -1 ||
rule[key] === undefined
) {
return;
}
normalized[key] = clonePlainData(rule[key]);
});
return normalized;
}
function compressSiteRules(siteRules, baseSettings) {
if (!Array.isArray(siteRules)) {
return {};
}
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
var currentPatterns = new Set();
var exportRules = [];
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (pattern) {
currentPatterns.add(pattern);
}
var normalizedRule = normalizeSiteRuleForDiff(rule, baseSettings);
if (!normalizedRule || Object.keys(normalizedRule).length === 1) {
return;
}
var defaultRule = pattern ? defaultRulesByPattern[pattern] : null;
var normalizedDefaultRule = defaultRule
? normalizeSiteRuleForDiff(defaultRule, baseSettings)
: null;
if (normalizedDefaultRule) {
if (areComparableValuesEqual(normalizedRule, normalizedDefaultRule)) {
return;
}
var defaultRuleDiff = deepDiff(normalizedRule, normalizedDefaultRule);
if (defaultRuleDiff && Object.keys(defaultRuleDiff).length > 0) {
defaultRuleDiff.pattern = pattern;
exportRules.push(defaultRuleDiff);
}
return;
}
exportRules.push(normalizedRule);
});
var removedDefaultPatterns = defaultRules
.map(function (rule) {
return rule && typeof rule.pattern === "string" ? rule.pattern : "";
})
.filter(function (pattern) {
return pattern && !currentPatterns.has(pattern);
});
var result = {};
if (exportRules.length > 0) {
result.siteRules = exportRules;
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
if (removedDefaultPatterns.length > 0) {
result.siteRulesMeta = {
removedDefaultPatterns: removedDefaultPatterns
};
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
return result;
}
function expandSiteRules(siteRules, siteRulesMeta) {
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
if (defaultRules.length === 0) {
return Array.isArray(siteRules) ? clonePlainData(siteRules) : [];
}
var removedDefaultPatterns = new Set(
siteRulesMeta && Array.isArray(siteRulesMeta.removedDefaultPatterns)
? siteRulesMeta.removedDefaultPatterns
: []
);
var modifiedDefaultRules = Object.create(null);
var customRules = [];
if (Array.isArray(siteRules)) {
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (
pattern &&
Object.prototype.hasOwnProperty.call(defaultRulesByPattern, pattern)
) {
modifiedDefaultRules[pattern] = clonePlainData(rule);
return;
}
customRules.push(clonePlainData(rule));
});
}
var mergedRules = [];
defaultRules.forEach(function (rule) {
var pattern = rule && typeof rule.pattern === "string" ? rule.pattern : "";
if (!pattern || removedDefaultPatterns.has(pattern)) {
return;
}
if (modifiedDefaultRules[pattern]) {
mergedRules.push(
Object.assign(
{},
clonePlainData(rule),
clonePlainData(modifiedDefaultRules[pattern])
)
);
return;
}
mergedRules.push(clonePlainData(rule));
});
customRules.forEach(function (rule) {
mergedRules.push(rule);
});
return mergedRules;
}
function buildStoredSettingsDiff(currentSettings) {
var defaults = clonePlainData(DEFAULT_SETTINGS);
var normalized = deepMergeDefaults(defaults, currentSettings || {});
var siteRuleData = compressSiteRules(normalized.siteRules, normalized);
var diffDefaults = {};
var diff = {};
delete normalized.siteRules;
delete normalized.siteRulesMeta;
delete normalized.siteRulesFormat;
delete normalized.hideWithYouTubeControls;
if (siteRuleData.siteRules) {
normalized.siteRules = siteRuleData.siteRules;
}
if (siteRuleData.siteRulesMeta) {
normalized.siteRulesMeta = siteRuleData.siteRulesMeta;
}
if (siteRuleData.siteRulesFormat) {
normalized.siteRulesFormat = siteRuleData.siteRulesFormat;
}
DIFFABLE_OPTION_KEYS.forEach(function (key) {
if (hasOwn(DEFAULT_SETTINGS, key)) {
diffDefaults[key] = clonePlainData(DEFAULT_SETTINGS[key]);
}
if (!hasOwn(normalized, key)) {
return;
}
var valueDiff = deepDiff(normalized[key], diffDefaults[key]);
if (valueDiff !== undefined) {
diff[key] = valueDiff;
}
});
return diff;
}
function expandStoredSettings(storage) {
var raw = clonePlainData(storage) || {};
var expanded = deepMergeDefaults(DEFAULT_SETTINGS, raw);
if (
!hasOwn(raw, "hideWithControls") &&
hasOwn(raw, "hideWithYouTubeControls")
) {
expanded.hideWithControls = Boolean(raw.hideWithYouTubeControls);
}
expanded.hideWithYouTubeControls = expanded.hideWithControls;
if (raw.siteRulesFormat === SITE_RULES_DIFF_FORMAT) {
expanded.siteRules = expandSiteRules(raw.siteRules, raw.siteRulesMeta);
} else if (Array.isArray(raw.siteRules)) {
expanded.siteRules = clonePlainData(raw.siteRules);
} else {
expanded.siteRules = getDefaultSiteRules();
}
return expanded;
}
function escapeStringRegExp(str) {
var matcher = /[|\\{}()[\]^$+*?.]/g;
return String(str).replace(matcher, "\\$&");
}
function siteRuleMatchesUrl(rule, currentUrl) {
if (!rule || !rule.pattern || !currentUrl) {
return false;
}
var pattern = String(rule.pattern).trim();
if (!pattern) {
return false;
}
var regex;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var lastSlash = pattern.lastIndexOf("/");
regex = new RegExp(
pattern.substring(1, lastSlash),
pattern.substring(lastSlash + 1)
);
} catch (_error) {
return false;
}
} else {
regex = new RegExp(escapeStringRegExp(pattern));
}
return Boolean(regex && regex.test(currentUrl));
}
function mergeMatchingSiteRules(currentUrl, siteRules) {
if (!currentUrl || !Array.isArray(siteRules)) {
return null;
}
var matchedRules = [];
for (var i = 0; i < siteRules.length; i++) {
if (siteRuleMatchesUrl(siteRules[i], currentUrl)) {
matchedRules.push(siteRules[i]);
}
}
if (!matchedRules.length) {
return null;
}
var mergedRule = {};
matchedRules.forEach(function (rule) {
Object.keys(rule).forEach(function (key) {
var value = rule[key];
if (Array.isArray(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
if (isPlainObject(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
mergedRule[key] = value;
});
});
return mergedRule;
}
function isSiteRuleDisabled(rule) {
return Boolean(
rule &&
(
rule.enabled === false ||
(typeof rule.enabled === "undefined" && rule.disableExtension === true)
)
);
}
global.vscClonePlainData = clonePlainData;
global.vscAreComparableValuesEqual = areComparableValuesEqual;
global.vscDeepMergeDefaults = deepMergeDefaults;
global.vscBuildStoredSettingsDiff = buildStoredSettingsDiff;
global.vscExpandStoredSettings = expandStoredSettings;
global.vscGetSettingsDefaults = function () {
return clonePlainData(DEFAULT_SETTINGS);
};
global.vscGetManagedSyncKeys = function () {
return MANAGED_SYNC_KEYS.slice();
};
global.vscGetSiteRulesDiffFormat = function () {
return SITE_RULES_DIFF_FORMAT;
};
global.vscMatchSiteRule = mergeMatchingSiteRules;
global.vscSiteRuleMatchesUrl = siteRuleMatchesUrl;
global.vscIsSiteRuleDisabled = isSiteRuleDisabled;
})(typeof globalThis !== "undefined" ? globalThis : this);
+216 -5
View File
@@ -4,51 +4,222 @@
font-size: 13px;
}
/* Global * uses 1.9em line-height; without this, every node inside #controller
(including svg) keeps a tall line box and the bar grows + content rides high. */
#controller * {
line-height: 1;
}
/* Show extra buttons on hover or keyboard :focus-visible only. Plain :focus-within
after a mouse click kept #controls visible while hover-only rules (e.g. draggable
margin) turned off when the pointer left the bar. */
#controller:hover #controls,
#controller:focus-within:has(:focus-visible) #controls,
:host(:hover) #controls {
display: inline;
display: inline-flex;
vertical-align: middle;
}
#controller {
position: absolute;
top: 0;
left: 0;
transform: translate(0, 0);
pointer-events: auto;
background: black;
color: white;
border-radius: 5px;
padding: 5px;
margin: 10px 10px 10px 15px;
margin: 0;
cursor: default;
z-index: 9999999;
/* Stay above the local video surface without overtaking page-level dialogs. */
z-index: 1;
transition: top 160ms ease, left 160ms ease, transform 160ms ease,
opacity 160ms ease;
/* Insets are baked into left/top; bar must not wrap when narrow. Hover controls
extend into the inset area (past “logical” margin). */
white-space: nowrap;
overflow: visible;
max-width: none;
}
#controller:hover {
opacity: 0.7;
}
/* Space between speed readout and hover buttons — tweak this value (px) as you like */
#controller:hover > .draggable {
margin-right: 0.8em;
margin-right: 5px;
}
/* Center presets: midpoint between left- and right-preset inset lines; center bar on that X. */
#controller[data-location="top-center"]:not([data-position-mode="manual"]) {
transform: translate(-50%, 0) !important;
}
#controller[data-location="bottom-center"]:not([data-position-mode="manual"]) {
transform: translate(-50%, -100%) !important;
}
#controls {
display: none;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 3px;
white-space: nowrap;
overflow: visible;
max-width: none;
}
/* Standalone flash next to speed when N is pressed — hidden = no layout footprint */
#nudge-flash-indicator {
display: none;
margin: 0;
padding: 0;
border: 0;
width: 0;
min-width: 0;
max-width: 0;
height: 0;
min-height: 0;
overflow: hidden;
vertical-align: middle;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 14px;
font-weight: bold;
font-family: "Lucida Console", Monaco, monospace;
box-sizing: border-box;
}
/* Same 24×24 footprint as #controls button */
#nudge-flash-indicator.visible {
display: inline-flex;
box-sizing: border-box;
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
max-width: 24px;
max-height: 24px;
margin-left: 5px;
padding: 0;
border-width: 1px;
border-style: solid;
border-radius: 5px;
align-items: center;
justify-content: center;
font-size: 0;
line-height: 0;
overflow: hidden;
flex-shrink: 0;
}
/* Hide flash indicator when hovering — the one in #controls is visible instead */
#controller:hover #nudge-flash-indicator,
:host(:hover) #nudge-flash-indicator {
display: none !important;
width: 0;
height: 0;
margin: 0;
padding: 0;
border: 0;
overflow: hidden;
}
#nudge-flash-indicator[data-enabled="true"] {
color: #fff;
background: #4b9135;
border-color: #6ec754;
}
#nudge-flash-indicator[data-enabled="false"] {
color: #fff;
background: #943e3e;
border-color: #c06060;
}
/* Same 24×24 chip as control buttons (Lucide check / x inside) */
#nudge-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
max-height: 24px;
padding: 0;
border-width: 1px;
border-style: solid;
border-radius: 5px;
font-size: 0;
line-height: 0;
cursor: pointer;
margin: 0;
flex-shrink: 0;
overflow: hidden;
}
#nudge-indicator[data-enabled="true"] {
color: #fff;
background: #4b9135;
border-color: #6ec754;
}
#nudge-indicator[data-enabled="false"] {
color: #fff;
background: #943e3e;
border-color: #c06060;
}
#nudge-indicator[data-supported="false"] {
opacity: 0.6;
}
#nudge-flash-indicator.visible .vsc-btn-icon,
#nudge-indicator .vsc-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin: 0;
padding: 0;
line-height: 0;
}
#nudge-flash-indicator.visible .vsc-btn-icon svg,
#nudge-indicator .vsc-btn-icon svg {
display: block;
width: 100%;
height: 100%;
flex-shrink: 0;
transform: translateY(0.5px);
}
#controller.dragging {
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
opacity: 0.7;
transition: none;
}
#controller.dragging #controls {
display: inline;
display: inline-flex;
vertical-align: middle;
}
.draggable {
cursor: -webkit-grab;
cursor: -moz-grab;
vertical-align: middle;
}
.draggable:active {
@@ -70,6 +241,46 @@ button {
margin-bottom: 2px;
}
/* Icon buttons: square targets, compact bar (no extra vertical stretch). */
#controls button {
box-sizing: border-box;
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
max-height: 24px;
padding: 0;
margin: 0;
border-width: 1px;
line-height: 0;
font-size: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
button .vsc-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin: 0;
padding: 0;
line-height: 0;
}
button .vsc-btn-icon svg {
display: block;
width: 100%;
height: 100%;
flex-shrink: 0;
/* Lucide 24×24 paths sit slightly high in the viewBox */
transform: translateY(0.5px);
}
button:focus {
outline: 0;
}
+55
View File
@@ -0,0 +1,55 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.controllerUtils = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
var CONTROLLER_MARGIN_MAX_PX = 200;
var controllerLocations = [
"top-left",
"top-center",
"top-right",
"middle-right",
"bottom-right",
"bottom-center",
"bottom-left",
"middle-left"
];
var defaultControllerLocation = controllerLocations[0];
function normalizeControllerLocation(location, fallback) {
if (controllerLocations.includes(location)) return location;
return typeof fallback === "string"
? fallback
: defaultControllerLocation;
}
function clampControllerMarginPx(value, fallback) {
var numericValue = Number(value);
if (!Number.isFinite(numericValue)) return fallback;
return Math.min(
CONTROLLER_MARGIN_MAX_PX,
Math.max(0, Math.round(numericValue))
);
}
function getNextControllerLocation(location) {
var normalizedLocation = normalizeControllerLocation(location);
var currentIndex = controllerLocations.indexOf(normalizedLocation);
return controllerLocations[(currentIndex + 1) % controllerLocations.length];
}
return {
CONTROLLER_MARGIN_MAX_PX: CONTROLLER_MARGIN_MAX_PX,
clampControllerMarginPx: clampControllerMarginPx,
controllerLocations: controllerLocations.slice(),
defaultControllerLocation: defaultControllerLocation,
getNextControllerLocation: getNextControllerLocation,
normalizeControllerLocation: normalizeControllerLocation
};
});
+148
View File
@@ -0,0 +1,148 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.importExport = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
var rawSettingsKeys = new Set([
"audioBoolean",
"controllerButtons",
"controllerLocation",
"controllerMarginBottom",
"controllerMarginLeft",
"controllerMarginRight",
"controllerMarginTop",
"controllerOpacity",
"enableSubtitleNudge",
"enabled",
"forceLastSavedSpeed",
"hideWithControls",
"hideWithControlsTimer",
"hideWithYouTubeControls",
"keyBindings",
"lastSpeed",
"popupControllerButtons",
"popupMatchHoverControls",
"rememberSpeed",
"showPopupControlBar",
"siteRules",
"speed",
"startHidden",
"subtitleNudgeAmount",
"subtitleNudgeInterval"
]);
function isRecognizedRawSettingsObject(backup) {
if (!backup || typeof backup !== "object" || Array.isArray(backup)) {
return false;
}
return Object.keys(backup).some(function(key) {
return rawSettingsKeys.has(key);
});
}
/**
* 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 lucide-client.js (LUCIDE_TAGS_CACHE_KEY + "At").
*/
var localSettingsKeysOmittedFromExport = [
"lucideTagsCacheV1",
"lucideTagsCacheV1At"
];
function filterLocalSettingsForExport(local) {
if (!local || typeof local !== "object" || Array.isArray(local)) {
return {};
}
var out = {};
for (var key in local) {
if (!Object.prototype.hasOwnProperty.call(local, key)) continue;
if (localSettingsKeysOmittedFromExport.indexOf(key) !== -1) continue;
out[key] = local[key];
}
return out;
}
function generateBackupFilename(now) {
var date = now instanceof Date ? now : new Date(now || Date.now());
var year = date.getFullYear();
var month = String(date.getMonth() + 1).padStart(2, "0");
var day = String(date.getDate()).padStart(2, "0");
var hours = String(date.getHours()).padStart(2, "0");
var minutes = String(date.getMinutes()).padStart(2, "0");
var seconds = String(date.getSeconds()).padStart(2, "0");
return (
"speeder-backup_" +
year +
"-" +
month +
"-" +
day +
"_" +
hours +
"." +
minutes +
"." +
seconds +
".json"
);
}
function buildBackupPayload(settings, localSettings, now) {
return {
version: "1.1",
exportDate: new Date(now || Date.now()).toISOString(),
settings: settings,
localSettings: localSettings || {}
};
}
function extractImportSettings(backup) {
var settingsToImport = null;
var isWrappedBackup = false;
if (backup && backup.settings && typeof backup.settings === "object") {
settingsToImport = backup.settings;
isWrappedBackup = true;
} else if (
backup &&
typeof backup === "object" &&
isRecognizedRawSettingsObject(backup)
) {
settingsToImport = backup;
}
if (!settingsToImport) return null;
return {
isWrappedBackup: isWrappedBackup,
settings: settingsToImport,
localSettings:
backup &&
backup.localSettings &&
typeof backup.localSettings === "object"
? backup.localSettings
: null
};
}
function parseImportText(text) {
return extractImportSettings(JSON.parse(text));
}
return {
buildBackupPayload: buildBackupPayload,
extractImportSettings: extractImportSettings,
filterLocalSettingsForExport: filterLocalSettingsForExport,
generateBackupFilename: generateBackupFilename,
isRecognizedRawSettingsObject: isRecognizedRawSettingsObject,
parseImportText: parseImportText
};
});
+122
View File
@@ -0,0 +1,122 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.keyBindings = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
function normalizeBindingKey(key) {
if (typeof key !== "string" || key.length === 0) return null;
if (key === "Spacebar") return " ";
if (key === "Esc") return "Escape";
if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase();
return key;
}
function getLegacyKeyCode(binding) {
if (!binding) return null;
if (Number.isInteger(binding.keyCode)) return binding.keyCode;
if (typeof binding.key === "number" && Number.isInteger(binding.key)) {
return binding.key;
}
return null;
}
function legacyBindingKeyToCode(key) {
var normalizedKey = normalizeBindingKey(key);
if (!normalizedKey) return null;
if (/^[A-Z]$/.test(normalizedKey)) return "Key" + normalizedKey;
if (/^[0-9]$/.test(normalizedKey)) return "Digit" + normalizedKey;
if (/^F([1-9]|1[0-2])$/.test(normalizedKey)) return normalizedKey;
var keyMap = {
" ": "Space",
ArrowLeft: "ArrowLeft",
ArrowUp: "ArrowUp",
ArrowRight: "ArrowRight",
ArrowDown: "ArrowDown",
";": "Semicolon",
"<": "Comma",
"-": "Minus",
"+": "Equal",
">": "Period",
"/": "Slash",
"~": "Backquote",
"[": "BracketLeft",
"\\": "Backslash",
"]": "BracketRight",
"'": "Quote"
};
return keyMap[normalizedKey] || null;
}
function legacyKeyCodeToCode(keyCode) {
if (!Number.isInteger(keyCode)) return null;
if (keyCode >= 48 && keyCode <= 57) return "Digit" + String.fromCharCode(keyCode);
if (keyCode >= 65 && keyCode <= 90) return "Key" + String.fromCharCode(keyCode);
if (keyCode >= 96 && keyCode <= 105) return "Numpad" + (keyCode - 96);
if (keyCode >= 112 && keyCode <= 123) return "F" + (keyCode - 111);
var keyCodeMap = {
32: "Space",
37: "ArrowLeft",
38: "ArrowUp",
39: "ArrowRight",
40: "ArrowDown",
106: "NumpadMultiply",
107: "NumpadAdd",
109: "NumpadSubtract",
110: "NumpadDecimal",
111: "NumpadDivide",
186: "Semicolon",
188: "Comma",
189: "Minus",
187: "Equal",
190: "Period",
191: "Slash",
192: "Backquote",
219: "BracketLeft",
220: "Backslash",
221: "BracketRight",
222: "Quote",
59: "Semicolon",
61: "Equal",
173: "Minus"
};
return keyCodeMap[keyCode] || null;
}
function inferBindingCode(binding, fallbackCode) {
if (binding && typeof binding.code === "string" && binding.code.length > 0) {
return binding.code;
}
if (binding && typeof binding.key === "string") {
var codeFromKey = legacyBindingKeyToCode(binding.key);
if (codeFromKey) return codeFromKey;
}
var legacyKeyCode = getLegacyKeyCode(binding);
if (Number.isInteger(legacyKeyCode)) {
var codeFromKeyCode = legacyKeyCodeToCode(legacyKeyCode);
if (codeFromKeyCode) return codeFromKeyCode;
}
return typeof fallbackCode === "string" && fallbackCode.length > 0
? fallbackCode
: null;
}
return {
getLegacyKeyCode: getLegacyKeyCode,
inferBindingCode: inferBindingCode,
legacyBindingKeyToCode: legacyBindingKeyToCode,
legacyKeyCodeToCode: legacyKeyCodeToCode,
normalizeBindingKey: normalizeBindingKey
};
});
+85
View File
@@ -0,0 +1,85 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.popupControls = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
function normalizeExcludedIds(excludedIds) {
if (excludedIds instanceof Set) return excludedIds;
return new Set(Array.isArray(excludedIds) ? excludedIds : []);
}
function sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds) {
if (!Array.isArray(buttonIds)) return [];
var seen = new Set();
var excluded = normalizeExcludedIds(excludedIds);
return buttonIds.filter(function(id) {
if (!controllerButtonDefs[id] || excluded.has(id) || seen.has(id)) {
return false;
}
seen.add(id);
return true;
});
}
function resolvePopupButtons(storage, siteRule, options) {
var settings = storage || {};
var config = options || {};
var controllerButtonDefs = config.controllerButtonDefs || {};
var defaultButtons = Array.isArray(config.defaultButtons)
? config.defaultButtons
: [];
var excludedIds = config.excludedIds;
function sanitize(buttonIds) {
return sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds);
}
if (siteRule && Array.isArray(siteRule.popupControllerButtons)) {
return sanitize(siteRule.popupControllerButtons);
}
if (settings.popupMatchHoverControls) {
if (siteRule && Array.isArray(siteRule.controllerButtons)) {
return sanitize(siteRule.controllerButtons);
}
if (Array.isArray(settings.controllerButtons)) {
return sanitize(settings.controllerButtons);
}
}
if (Array.isArray(settings.popupControllerButtons)) {
return sanitize(settings.popupControllerButtons);
}
return sanitize(defaultButtons);
}
function pickBestFrameSpeedResult(results) {
if (!results || !results.length) return null;
var fallback = null;
for (var i = 0; i < results.length; i++) {
var result = results[i];
if (!result || typeof result.speed !== "number") continue;
if (result.preferred) return { speed: result.speed };
if (!fallback) fallback = { speed: result.speed };
}
return fallback;
}
return {
pickBestFrameSpeedResult: pickBestFrameSpeedResult,
resolvePopupButtons: resolvePopupButtons,
sanitizeButtonOrder: sanitizeButtonOrder
};
});
+87
View File
@@ -0,0 +1,87 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.siteRules = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
function escapeStringRegExp(str) {
return String(str).replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
}
function compileSiteRulePattern(pattern) {
if (typeof pattern !== "string") return null;
var normalizedPattern = pattern.replace(regStrip, "");
if (normalizedPattern.length === 0) return null;
if (
normalizedPattern.startsWith("/") &&
normalizedPattern.lastIndexOf("/") > 0
) {
var lastSlash = normalizedPattern.lastIndexOf("/");
return new RegExp(
normalizedPattern.substring(1, lastSlash),
normalizedPattern.substring(lastSlash + 1)
);
}
return new RegExp(escapeStringRegExp(normalizedPattern));
}
function matchSiteRule(url, siteRules) {
if (!url || !Array.isArray(siteRules)) return null;
for (var i = 0; i < siteRules.length; i++) {
var rule = siteRules[i];
if (!rule || !rule.pattern) continue;
try {
var re = compileSiteRulePattern(rule.pattern);
if (re && re.test(url)) {
return rule;
}
} catch (e) {
}
}
return null;
}
function isSiteRuleDisabled(rule) {
return Boolean(
rule &&
(rule.enabled === false || rule.disableExtension === true)
);
}
/**
* 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
};
});
+220
View File
@@ -0,0 +1,220 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import { vi } from "vitest";
import { applyJSDOMWindow } from "./jsdom-globals.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", "..");
function readRepoFile(relPath) {
return fs.readFileSync(path.join(repoRoot, relPath), "utf8");
}
/**
* Parse HTML into a fresh JSDOM document so tests can reload scripts without
* top-level `const` redeclaration errors (avoids document.write).
*/
export function loadHtmlString(html) {
const dom = new JSDOM(html, {
url: "https://example.org/",
pretendToBeVisual: true,
runScripts: "dangerously"
});
applyJSDOMWindow(dom.window);
}
export function loadHtml(relPath) {
loadHtmlString(readRepoFile(relPath));
}
const WINDOW_GLOBAL_SKIP = new Set([
"alert",
"atob",
"blur",
"btoa",
"cancelAnimationFrame",
"captureEvents",
"clearInterval",
"clearTimeout",
"close",
"confirm",
"fetch",
"focus",
"getComputedStyle",
"matchMedia",
"open",
"prompt",
"queueMicrotask",
"releaseEvents",
"requestAnimationFrame",
"setInterval",
"setTimeout",
"stop"
]);
function mirrorExtensionGlobalsFromWindow(win) {
if (!win) return;
if (win.tc) {
globalThis.tc = win.tc;
}
for (const key of Object.keys(win)) {
if (WINDOW_GLOBAL_SKIP.has(key)) continue;
if (/^[A-Z]/.test(key)) continue;
const val = win[key];
if (typeof val === "function") {
globalThis[key] = val;
}
}
}
export function loadScript(relPath) {
const source =
"var chrome = window.chrome || globalThis.chrome;\n" +
readRepoFile(relPath) +
"\n//# sourceURL=" +
relPath;
const el = document.createElement("script");
el.textContent = source;
document.head.appendChild(el);
mirrorExtensionGlobalsFromWindow(window);
}
export async function flushAsyncWork() {
await Promise.resolve();
await Promise.resolve();
}
export function triggerDomContentLoaded() {
document.dispatchEvent(
new window.Event("DOMContentLoaded", {
bubbles: true,
cancelable: true
})
);
}
function createEvent() {
const listeners = [];
return {
addListener(listener) {
listeners.push(listener);
},
trigger(...args) {
listeners.forEach((listener) => listener(...args));
},
listeners
};
}
function createStorageArea(initialState = {}) {
const state = { ...initialState };
function resolveGet(keys) {
if (keys == null) return { ...state };
if (Array.isArray(keys)) {
return keys.reduce((acc, key) => {
if (Object.prototype.hasOwnProperty.call(state, key)) {
acc[key] = state[key];
}
return acc;
}, {});
}
if (typeof keys === "string") {
return Object.prototype.hasOwnProperty.call(state, keys)
? { [keys]: state[keys] }
: {};
}
if (typeof keys === "object") {
const result = { ...keys };
Object.keys(state).forEach((key) => {
result[key] = state[key];
});
return result;
}
return {};
}
return {
__state: state,
get: vi.fn((keys, callback) => {
callback(resolveGet(keys));
}),
set: vi.fn((items, callback) => {
Object.assign(state, items);
if (callback) callback();
}),
remove: vi.fn((keys, callback) => {
const list = Array.isArray(keys) ? keys : [keys];
list.forEach((key) => {
delete state[key];
});
if (callback) callback();
}),
clear: vi.fn((callback) => {
Object.keys(state).forEach((key) => delete state[key]);
if (callback) callback();
})
};
}
export function createChromeMock(options = {}) {
const syncArea = createStorageArea(options.sync ?? {});
const localArea = createStorageArea(options.local ?? {});
const tabsOnActivated = createEvent();
const tabsOnUpdated = createEvent();
const storageOnChanged = createEvent();
const chrome = {
runtime: {
lastError: null,
getManifest: vi.fn(() => ({
version: options.manifestVersion || "9.9.9"
})),
getURL: vi.fn((url) => "moz-extension://speeder/" + url)
},
storage: {
sync: syncArea,
local: localArea,
onChanged: storageOnChanged
},
tabs: {
query: vi.fn((queryInfo, callback) => {
callback(
options.tabs ??
[
{
id: 1,
active: true,
url: "https://example.com/watch"
}
]
);
}),
sendMessage: vi.fn((tabId, message, callback) => {
if (callback) {
callback(options.sendMessageResponse ?? { speed: 1.25 });
}
}),
executeScript: vi.fn((tabId, details, callback) => {
if (callback) {
callback(
options.executeScriptResponse ?? [
{ speed: 1.25, preferred: true }
]
);
}
}),
create: vi.fn(),
onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated
},
browserAction: {
setIcon: vi.fn()
}
};
return chrome;
}
+309
View File
@@ -0,0 +1,309 @@
const fs = require("fs");
const path = require("path");
const { JSDOM } = require("jsdom");
const { vi } = require("vitest");
const ROOT = path.resolve(__dirname, "..", "..");
function clone(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function workspacePath(relPath) {
return path.join(ROOT, relPath);
}
function readWorkspaceFile(relPath) {
return fs.readFileSync(workspacePath(relPath), "utf8");
}
function loadHtmlFile(relPath) {
loadHtmlString(readWorkspaceFile(relPath));
}
function applyJSDOMWindow(win) {
globalThis.window = win;
globalThis.document = win.document;
globalThis.navigator = win.navigator;
globalThis.customElements = win.customElements;
globalThis.HTMLElement = win.HTMLElement;
globalThis.Element = win.Element;
globalThis.Node = win.Node;
globalThis.Text = win.Text;
globalThis.DocumentFragment = win.DocumentFragment;
globalThis.Event = win.Event;
globalThis.MouseEvent = win.MouseEvent;
globalThis.KeyboardEvent = win.KeyboardEvent;
globalThis.DOMParser = win.DOMParser;
globalThis.URL = win.URL;
globalThis.Blob = win.Blob;
globalThis.FileReader = win.FileReader;
win.Date = globalThis.Date;
win.open = vi.fn();
win.close = vi.fn();
}
function loadHtmlString(html) {
const dom = new JSDOM(html, {
url: "https://example.org/",
pretendToBeVisual: true,
runScripts: "dangerously"
});
applyJSDOMWindow(dom.window);
}
const WINDOW_GLOBAL_SKIP = new Set([
"alert",
"atob",
"blur",
"btoa",
"cancelAnimationFrame",
"captureEvents",
"clearInterval",
"clearTimeout",
"close",
"confirm",
"fetch",
"focus",
"getComputedStyle",
"matchMedia",
"open",
"prompt",
"queueMicrotask",
"releaseEvents",
"requestAnimationFrame",
"setInterval",
"setTimeout",
"stop"
]);
function mirrorExtensionGlobalsFromWindow(win) {
if (!win) return;
if (win.tc) {
globalThis.tc = win.tc;
}
for (const key of Object.keys(win)) {
if (WINDOW_GLOBAL_SKIP.has(key)) continue;
if (/^[A-Z]/.test(key)) continue;
const val = win[key];
if (typeof val === "function") {
globalThis[key] = val;
}
}
}
function evaluateScript(relPath) {
const absPath = workspacePath(relPath);
const source =
"var chrome = window.chrome || globalThis.chrome;\n" +
readWorkspaceFile(relPath) +
`\n//# sourceURL=${absPath.replace(/\\/g, "/")}`;
const el = document.createElement("script");
el.textContent = source;
document.head.appendChild(el);
mirrorExtensionGlobalsFromWindow(window);
}
function fireDOMContentLoaded() {
document.dispatchEvent(
new window.Event("DOMContentLoaded", {
bubbles: true,
cancelable: true
})
);
}
async function flushAsyncWork(turns) {
const count = turns || 2;
for (let i = 0; i < count; i += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function pickStorageValues(data, keys) {
if (keys == null) return clone(data || {});
if (typeof keys === "string") {
return { [keys]: clone(data ? data[keys] : undefined) };
}
if (Array.isArray(keys)) {
const result = {};
keys.forEach((key) => {
result[key] = clone(data ? data[key] : undefined);
});
return result;
}
if (typeof keys === "object") {
const result = clone(keys) || {};
Object.keys(keys).forEach((key) => {
if (data && Object.prototype.hasOwnProperty.call(data, key)) {
result[key] = clone(data[key]);
}
});
return result;
}
return {};
}
function createChromeEvent() {
const listeners = [];
return {
addListener(listener) {
listeners.push(listener);
},
removeListener(listener) {
const index = listeners.indexOf(listener);
if (index >= 0) {
listeners.splice(index, 1);
}
},
hasListener(listener) {
return listeners.includes(listener);
},
emit(...args) {
listeners.slice().forEach((listener) => listener(...args));
},
listeners
};
}
function createStorageArea(areaName, initialData, onChangedEvent) {
let data = clone(initialData) || {};
function emitChanges(changes) {
if (changes && Object.keys(changes).length > 0) {
onChangedEvent.emit(changes, areaName);
}
}
return {
get: vi.fn((keys, callback) => {
if (callback) callback(pickStorageValues(data, keys));
}),
set: vi.fn((items, callback) => {
const nextItems = items || {};
const changes = {};
Object.keys(nextItems).forEach((key) => {
const oldValue = clone(data[key]);
const newValue = clone(nextItems[key]);
data[key] = newValue;
changes[key] = { oldValue, newValue };
});
emitChanges(changes);
if (callback) callback();
}),
remove: vi.fn((keys, callback) => {
const list = Array.isArray(keys) ? keys : [keys];
const changes = {};
list.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(data, key)) {
changes[key] = {
oldValue: clone(data[key]),
newValue: undefined
};
delete data[key];
}
});
emitChanges(changes);
if (callback) callback();
}),
clear: vi.fn((callback) => {
const changes = {};
Object.keys(data).forEach((key) => {
changes[key] = {
oldValue: clone(data[key]),
newValue: undefined
};
});
data = {};
emitChanges(changes);
if (callback) callback();
}),
_dump() {
return clone(data);
}
};
}
function createChromeMock(options) {
const config = options || {};
const storageOnChanged = createChromeEvent();
const tabsOnActivated = createChromeEvent();
const tabsOnUpdated = createChromeEvent();
const runtimeOnMessage = createChromeEvent();
const chrome = {
runtime: {
lastError: null,
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
getURL: vi.fn((relPath) => `moz-extension://${relPath}`),
onMessage: runtimeOnMessage
},
browserAction: {
setIcon: vi.fn()
},
tabs: {
query: vi.fn((queryInfo, callback) => {
const tabs = clone(config.tabsQueryResult) || [
{ id: 1, active: true, url: "https://example.com/" }
];
if (callback) callback(tabs);
}),
sendMessage: vi.fn((tabId, message, callback) => {
if (callback) callback(null);
}),
executeScript: vi.fn((tabId, details, callback) => {
if (callback) callback([]);
}),
create: vi.fn(),
onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated
},
storage: {
onChanged: storageOnChanged,
sync: null,
local: null
}
};
chrome.storage.sync = createStorageArea(
"sync",
config.syncData,
storageOnChanged
);
chrome.storage.local = createStorageArea(
"local",
config.localData,
storageOnChanged
);
return chrome;
}
function installCommonWindowMocks() {
window.open = vi.fn();
window.close = vi.fn();
window.requestAnimationFrame = vi.fn((callback) => setTimeout(callback, 0));
window.cancelAnimationFrame = vi.fn((id) => clearTimeout(id));
}
module.exports = {
createChromeMock,
evaluateScript,
fireDOMContentLoaded,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlFile,
loadHtmlString,
readWorkspaceFile,
workspacePath
};
+30
View File
@@ -0,0 +1,30 @@
/**
* Point Vitest/jsdom test globals at a new JSDOM window (no document.write).
* Call after creating `new JSDOM(html, options).window`.
*/
import { vi } from "vitest";
export function applyJSDOMWindow(win) {
globalThis.window = win;
globalThis.document = win.document;
globalThis.navigator = win.navigator;
globalThis.customElements = win.customElements;
globalThis.HTMLElement = win.HTMLElement;
globalThis.Element = win.Element;
globalThis.Node = win.Node;
globalThis.Text = win.Text;
globalThis.DocumentFragment = win.DocumentFragment;
globalThis.Event = win.Event;
globalThis.MouseEvent = win.MouseEvent;
globalThis.KeyboardEvent = win.KeyboardEvent;
globalThis.DOMParser = win.DOMParser;
globalThis.URL = win.URL;
globalThis.Blob = win.Blob;
globalThis.FileReader = win.FileReader;
// Vitest fake timers patch host `Date`; jsdoms window keeps its own otherwise.
win.Date = globalThis.Date;
win.open = vi.fn();
win.close = vi.fn();
}
+339
View File
@@ -0,0 +1,339 @@
import {
createChromeMock,
flushAsyncWork,
loadHtml,
loadScript
} from "./helpers/browser.js";
async function setupImportExport(overrides = {}) {
loadHtml("options.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
const restoreSpy = vi.fn();
globalThis.restore_options = restoreSpy;
window.restore_options = restoreSpy;
loadScript("shared/import-export.js");
loadScript("importExport.js");
await flushAsyncWork();
return globalThis.chrome;
}
describe("import/export flows", () => {
it("exports sync and local settings as a JSON download", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
const chrome = await setupImportExport({
sync: { rememberSpeed: true },
local: { customButtonIcons: { faster: { slug: "rocket" } } }
});
const OriginalBlob = window.Blob;
class TestBlob {
constructor(parts, options) {
this.parts = parts;
this.options = options;
}
async text() {
return this.parts.join("");
}
}
globalThis.Blob = TestBlob;
window.Blob = TestBlob;
let capturedBlob = null;
let clickedDownload = null;
Object.defineProperty(window.URL, "createObjectURL", {
configurable: true,
value: vi.fn((blob) => {
capturedBlob = blob;
return "blob:test";
})
});
Object.defineProperty(window.URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(() => {})
});
vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation(
function () {
clickedDownload = this.download;
}
);
document.getElementById("exportSettings").click();
expect(clickedDownload).toBe("speeder-backup_2026-04-04_08.09.10.json");
expect(capturedBlob).not.toBeNull();
const blobText = await capturedBlob.text();
expect(JSON.parse(blobText)).toEqual({
version: "1.1",
exportDate: "2026-04-04T12:09:10.000Z",
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
});
expect(document.getElementById("status").textContent).toBe(
"Settings exported successfully"
);
expect(chrome.storage.sync.get).toHaveBeenCalled();
expect(chrome.storage.local.get).toHaveBeenCalled();
globalThis.Blob = OriginalBlob;
window.Blob = OriginalBlob;
});
it("export strips lucideTagsCacheV1 from localSettings", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
await setupImportExport({
sync: { rememberSpeed: true },
local: {
customButtonIcons: { faster: { slug: "rocket", svg: "<svg/>" } },
lucideTagsCacheV1: { "a-arrow-down": ["letter"] },
lucideTagsCacheV1At: 42
}
});
const OriginalBlob = window.Blob;
class TestBlob {
constructor(parts) {
this.parts = parts;
}
async text() {
return this.parts.join("");
}
}
globalThis.Blob = TestBlob;
window.Blob = TestBlob;
let capturedBlob = null;
Object.defineProperty(window.URL, "createObjectURL", {
configurable: true,
value: vi.fn((blob) => {
capturedBlob = blob;
return "blob:test";
})
});
Object.defineProperty(window.URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(() => {})
});
vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation(
() => {}
);
document.getElementById("exportSettings").click();
await flushAsyncWork();
expect(JSON.parse(await capturedBlob.text()).localSettings).toEqual({
customButtonIcons: { faster: { slug: "rocket", svg: "<svg/>" } }
});
globalThis.Blob = OriginalBlob;
window.Blob = OriginalBlob;
});
it("imports wrapped backup payloads and refreshes options", async () => {
vi.useFakeTimers();
const chrome = await setupImportExport();
const originalCreateElement = document.createElement.bind(document);
let createdInput = null;
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
const el = originalCreateElement(tagName);
if (tagName === "input") {
createdInput = el;
el.click = vi.fn();
}
return el;
});
class MockFileReader {
readAsText(file) {
this.onload({
target: {
result: file.__text
}
});
}
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
globalThis.importSettings();
createdInput.onchange({
target: {
files: [
{
__text: JSON.stringify({
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
})
}
]
}
});
expect(chrome.storage.local.set).toHaveBeenCalledWith(
{ customButtonIcons: { faster: { slug: "rocket" } } },
expect.any(Function)
);
expect(chrome.storage.sync.clear).toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true },
expect.any(Function)
);
expect(document.getElementById("status").textContent).toBe(
"Settings imported successfully. Reloading..."
);
vi.advanceTimersByTime(500);
expect(globalThis.restore_options).toHaveBeenCalled();
});
it("imports raw settings objects without touching local storage", async () => {
vi.useFakeTimers();
const chrome = await setupImportExport({
local: { customButtonIcons: { faster: { slug: "rocket" } } }
});
const originalCreateElement = document.createElement.bind(document);
let createdInput = null;
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
const el = originalCreateElement(tagName);
if (tagName === "input") {
createdInput = el;
el.click = vi.fn();
}
return el;
});
class MockFileReader {
readAsText(file) {
this.onload({
target: {
result: file.__text
}
});
}
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
globalThis.importSettings();
createdInput.onchange({
target: {
files: [
{
__text: JSON.stringify({
enabled: false,
siteRules: [{ pattern: "example.com", enabled: false }]
})
}
]
}
});
expect(chrome.storage.local.clear).not.toHaveBeenCalled();
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{
enabled: false,
siteRules: [{ pattern: "example.com", enabled: false }]
},
expect.any(Function)
);
});
it("clears stale local data when a wrapped backup has empty local settings", async () => {
vi.useFakeTimers();
const chrome = await setupImportExport({
local: {
customButtonIcons: { faster: { slug: "rocket" } },
lucideTagsCacheV1: { stale: true }
}
});
const originalCreateElement = document.createElement.bind(document);
let createdInput = null;
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
const el = originalCreateElement(tagName);
if (tagName === "input") {
createdInput = el;
el.click = vi.fn();
}
return el;
});
class MockFileReader {
readAsText(file) {
this.onload({
target: {
result: file.__text
}
});
}
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
globalThis.importSettings();
createdInput.onchange({
target: {
files: [
{
__text: JSON.stringify({
settings: { rememberSpeed: true },
localSettings: {}
})
}
]
}
});
expect(chrome.storage.local.clear).toHaveBeenCalled();
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true },
expect.any(Function)
);
});
it("shows an error for invalid backup files", async () => {
vi.useFakeTimers();
const chrome = await setupImportExport();
const originalCreateElement = document.createElement.bind(document);
let createdInput = null;
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
const el = originalCreateElement(tagName);
if (tagName === "input") {
createdInput = el;
el.click = vi.fn();
}
return el;
});
class MockFileReader {
readAsText(file) {
this.onload({
target: {
result: file.__text
}
});
}
}
globalThis.FileReader = MockFileReader;
window.FileReader = MockFileReader;
globalThis.importSettings();
createdInput.onchange({
target: {
files: [
{
__text: JSON.stringify({ wat: true })
}
]
}
});
expect(document.getElementById("status").textContent).toBe(
"Error: Invalid backup file format"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
});
+219
View File
@@ -0,0 +1,219 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlString
} = require("./helpers/extension-test-utils");
function bootImportExport(options) {
const config = options || {};
loadHtmlString(`<!doctype html><html><body>
<button id="exportSettings">Export</button>
<button id="importSettings">Import</button>
<div id="status"></div>
</body></html>`);
installCommonWindowMocks();
const chrome = createChromeMock({
syncData: config.syncData,
localData: config.localData
});
global.chrome = chrome;
window.chrome = chrome;
const createObjectURL = vi.fn(() => "blob:test");
const revokeObjectURL = vi.fn();
vi.stubGlobal("URL", {
createObjectURL,
revokeObjectURL
});
evaluateScript("importExport.js");
return { chrome, createObjectURL, revokeObjectURL };
}
describe("importExport.js", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
});
it("generates timestamped backup filenames", () => {
vi.setSystemTime(new Date("2026-04-04T13:14:15Z"));
bootImportExport();
expect(window.generateBackupFilename()).toBe(
"speeder-backup_2026-04-04_13.14.15.json"
);
});
it("exports sync and local settings into a downloadable backup", async () => {
const clickSpy = vi
.spyOn(window.HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
const { createObjectURL, revokeObjectURL } = bootImportExport({
syncData: {
rememberSpeed: true,
keyBindings: [{ action: "faster", code: "KeyD", value: 0.1 }]
},
localData: {
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
}
});
document.querySelector("#exportSettings").click();
await flushAsyncWork();
expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0];
const backup = JSON.parse(await blob.text());
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 () => {
vi.spyOn(window.HTMLAnchorElement.prototype, "click").mockImplementation(
() => {}
);
const { createObjectURL } = bootImportExport({
syncData: { rememberSpeed: true },
localData: {
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
},
lucideTagsCacheV1: { "a-arrow-down": ["letter", "text"] },
lucideTagsCacheV1At: 999
}
});
document.querySelector("#exportSettings").click();
await flushAsyncWork();
const blob = createObjectURL.mock.calls[0][0];
const backup = JSON.parse(await blob.text());
expect(backup.localSettings).toEqual({
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
});
expect(backup.localSettings.lucideTagsCacheV1).toBeUndefined();
expect(backup.localSettings.lucideTagsCacheV1At).toBeUndefined();
});
it("imports wrapped backups, restores local data, and refreshes the options page", async () => {
const { chrome } = bootImportExport();
window.restore_options = vi.fn();
const realCreateElement = document.createElement.bind(document);
const fakeInput = realCreateElement("input");
Object.defineProperty(fakeInput, "files", {
configurable: true,
value: [
{
__contents: JSON.stringify({
settings: {
rememberSpeed: true,
enabled: false
},
localSettings: {
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
}
})
}
]
});
fakeInput.click = vi.fn(() => {
fakeInput.onchange({ target: fakeInput });
});
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (String(tagName).toLowerCase() === "input") {
return fakeInput;
}
return realCreateElement(tagName);
});
class FakeFileReader {
readAsText(file) {
this.onload({ target: { result: file.__contents } });
}
}
vi.stubGlobal("FileReader", FakeFileReader);
document.querySelector("#importSettings").click();
await flushAsyncWork();
expect(chrome.storage.local.set).toHaveBeenCalledWith(
{
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
},
expect.any(Function)
);
expect(chrome.storage.sync.clear).toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true, enabled: false },
expect.any(Function)
);
vi.advanceTimersByTime(500);
expect(window.restore_options).toHaveBeenCalled();
});
it("shows an error for malformed backups", async () => {
bootImportExport();
const realCreateElement = document.createElement.bind(document);
const fakeInput = realCreateElement("input");
Object.defineProperty(fakeInput, "files", {
configurable: true,
value: [{ __contents: "{bad json" }]
});
fakeInput.click = vi.fn(() => {
fakeInput.onchange({ target: fakeInput });
});
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (String(tagName).toLowerCase() === "input") {
return fakeInput;
}
return realCreateElement(tagName);
});
class FakeFileReader {
readAsText(file) {
this.onload({ target: { result: file.__contents } });
}
}
vi.stubGlobal("FileReader", FakeFileReader);
document.querySelector("#importSettings").click();
await flushAsyncWork();
expect(document.querySelector("#status").textContent).toContain(
"Failed to parse backup file"
);
});
});
+141
View File
@@ -0,0 +1,141 @@
const { afterEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
flushAsyncWork,
loadHtmlString
} = require("./helpers/extension-test-utils");
function bootInject(options) {
const config = options || {};
loadHtmlString("<!doctype html><html><body></body></html>");
const chrome = createChromeMock({
syncData: config.syncData,
localData: config.localData
});
global.chrome = chrome;
window.chrome = chrome;
window.requestIdleCallback = (callback, opts) =>
setTimeout(
() =>
callback({
didTimeout: false,
timeRemaining() {
return 1;
}
}),
(opts && opts.timeout) || 0
);
window.cancelIdleCallback = (id) => clearTimeout(id);
evaluateScript("ui-icons.js");
evaluateScript("inject.js");
return chrome;
}
describe("inject.js helper logic", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
});
it("normalizes bindings from legacy formats", async () => {
bootInject();
await flushAsyncWork(3);
expect(
window.normalizeStoredBinding({
action: "faster",
key: "g",
value: 1.8,
force: false
}).code
).toBe("KeyG");
expect(
window.normalizeStoredBinding({
action: "pause",
code: null,
key: null,
keyCode: null,
value: 0
})
).toEqual({
action: "pause",
code: null,
disabled: true,
value: 0,
force: "false",
predefined: false
});
expect(window.defaultKeyBindings({ speedStep: 0.25, rewindTime: 5 })[0]).toEqual(
{
action: "slower",
code: "KeyS",
value: 0.25,
force: false,
predefined: true
}
);
});
it("clamps controller margins and ignores stale source-specific target speeds", async () => {
bootInject();
await flushAsyncWork(3);
expect(window.normalizeControllerMarginPx(250, 0)).toBe(200);
expect(window.normalizeControllerMarginPx(-5, 65)).toBe(0);
expect(window.normalizeControllerMarginPx("bad", 65)).toBe(65);
const staleVideo = {
currentSrc: "fresh.mp4",
vsc: {
targetSpeed: 1.75,
targetSpeedSourceKey: "old.mp4"
}
};
expect(window.getControllerTargetSpeed(staleVideo)).toBeNull();
window.tc.settings.rememberSpeed = true;
window.tc.settings.forceLastSavedSpeed = false;
window.tc.settings.lastSpeed = 1.3;
window.tc.settings.speeds = { "fresh.mp4": 1.6 };
expect(window.getRememberedSpeed({ currentSrc: "fresh.mp4" })).toBe(1.6);
expect(window.getDesiredSpeed(staleVideo)).toBe(1.6);
});
it("applies site rule overrides and detects disabled sites", async () => {
bootInject();
await flushAsyncWork(3);
window.tc.settings.siteRules = [{ pattern: "localhost", enabled: false }];
window.captureSiteRuleBase();
expect(window.applySiteRuleOverrides()).toBe(true);
window.resetSettingsFromSiteRuleBase();
window.tc.settings.siteRules = [
{
pattern: "localhost",
controllerLocation: "bottom-left",
controllerMarginTop: 300,
controllerMarginBottom: -10,
rememberSpeed: true
}
];
window.captureSiteRuleBase();
expect(window.applySiteRuleOverrides()).toBe(false);
expect(window.tc.settings.controllerLocation).toBe("bottom-left");
expect(window.tc.settings.controllerMarginTop).toBe(200);
expect(window.tc.settings.controllerMarginBottom).toBe(0);
expect(window.tc.settings.rememberSpeed).toBe(true);
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from "vitest";
import {
createChromeMock,
flushAsyncWork,
loadHtmlString,
loadScript
} from "./helpers/browser.js";
function loadBlankDocument() {
loadHtmlString("<!doctype html><html><body></body></html>");
}
async function bootInject({ sync = {}, local = {} } = {}) {
loadBlankDocument();
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));
});
globalThis.requestIdleCallback = (callback, options) =>
setTimeout(
() =>
callback({
didTimeout: false,
timeRemaining() {
return 1;
}
}),
(options && options.timeout) || 0
);
globalThis.cancelIdleCallback = (id) => clearTimeout(id);
loadScript("shared/controller-utils.js");
loadScript("shared/key-bindings.js");
loadScript("shared/site-rules.js");
loadScript("ui-icons.js");
loadScript("inject.js");
for (let i = 0; i < 3; i += 1) {
await flushAsyncWork();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
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: {
enableSubtitleNudge: false
}
});
const stopSubtitleNudge = vi.fn();
const startSubtitleNudge = vi.fn();
const flashEl = document.createElement("span");
const video = {
paused: false,
playbackRate: 1.5,
vsc: {
stopSubtitleNudge,
startSubtitleNudge,
subtitleNudgeEnabledOverride: null,
subtitleNudgeIndicator: null,
nudgeFlashIndicator: flashEl
}
};
expect(window.tc.settings.enableSubtitleNudge).toBe(false);
expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(false);
expect(window.setSubtitleNudgeEnabledForVideo(video, true)).toBe(false);
expect(video.vsc.subtitleNudgeEnabledOverride).toBeNull();
expect(stopSubtitleNudge).toHaveBeenCalledTimes(1);
expect(startSubtitleNudge).not.toHaveBeenCalled();
expect(flashEl.classList.contains("visible")).toBe(false);
window.tc.settings.enableSubtitleNudge = true;
expect(window.setSubtitleNudgeEnabledForVideo(video, true)).toBe(true);
expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(true);
window.tc.settings.enableSubtitleNudge = false;
expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 0));
});
});
+61
View File
@@ -0,0 +1,61 @@
const { afterEach, describe, expect, it } = require("vitest");
const {
evaluateScript,
loadHtmlString
} = require("./helpers/extension-test-utils");
describe("lucide-client.js", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("builds icon URLs and rejects invalid slugs", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
expect(window.lucideIconSvgUrl("alarm-clock")).toContain(
"/icons/alarm-clock.svg"
);
expect(window.lucideIconSvgUrl("bad slug!!")).toBe("");
expect(window.lucideTagsJsonUrl()).toContain("/tags.json");
});
it("sanitizes SVG before persisting a Lucide icon", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
const sanitized = window.sanitizeLucideSvg(`
<svg width="10" height="10" onclick="evil()">
<script>alert(1)</script>
<foreignObject>bad</foreignObject>
<path d="M0 0h10v10"></path>
</svg>
`);
expect(sanitized).toContain("<svg");
expect(sanitized).not.toContain("onclick");
expect(sanitized).not.toContain("<script");
expect(sanitized).not.toContain("foreignObject");
expect(sanitized).toContain('width="100%"');
});
it("searches and ranks icon slugs by query", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
const results = window.searchLucideSlugs(
{
alarm: ["clock", "time"],
"badge-alert": ["alert", "warning"],
calendar: ["date", "time"]
},
"al",
10
);
expect(results).toEqual(["alarm", "badge-alert", "calendar"]);
});
});
+220
View File
@@ -0,0 +1,220 @@
import {
createChromeMock,
flushAsyncWork,
loadHtml,
loadScript,
triggerDomContentLoaded
} from "./helpers/browser.js";
async function setupOptions(overrides = {}) {
loadHtml("options.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
globalThis.fetch = vi.fn();
loadScript("shared/controller-utils.js");
loadScript("shared/key-bindings.js");
loadScript("shared/popup-controls.js");
loadScript("ui-icons.js");
loadScript("lucide-client.js");
loadScript("options.js");
triggerDomContentLoaded();
await flushAsyncWork();
return globalThis.chrome;
}
describe("options page", () => {
it("restores stored settings, custom shortcuts, and site rules", async () => {
await setupOptions({
manifestVersion: "5.1.7.0",
sync: {
rememberSpeed: true,
enabled: false,
popupMatchHoverControls: false,
popupControllerButtons: ["rewind", "settings", "advance", "advance"],
keyBindings: [
{ action: "display", code: "KeyV", value: 0, predefined: true },
{ action: "pause", code: "KeyQ", value: 0, predefined: false }
],
siteRules: [
{
pattern: "youtube.com",
enabled: true,
showPopupControlBar: false,
popupControllerButtons: ["advance", "settings", "advance"]
}
]
}
});
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.querySelector('.shortcut-row[data-action="pause"]')).not.toBe(
null
);
expect(document.getElementById("siteRulesContainer").children.length).toBe(
1
);
expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]);
});
it("validates site rule regexes before saving", async () => {
const chrome = await setupOptions();
chrome.storage.sync.set.mockClear();
globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule");
rule.querySelector(".site-pattern").value = "/(/";
globalThis.save_options();
expect(document.getElementById("status").textContent).toContain(
"Invalid site rule regex"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
it("shows a more-menu trigger for collapsed site rules and a collapse trigger when open", async () => {
await setupOptions({ sync: { siteRules: [] } });
globalThis.createSiteRule({ pattern: "youtube.com" });
const rule = document.getElementById("siteRulesContainer").lastElementChild;
const toggle = rule.querySelector(".toggle-site-rule");
const body = rule.querySelector(".site-rule-body");
expect(rule.classList.contains("collapsed")).toBe(true);
expect(body.style.display).toBe("none");
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(toggle.getAttribute("aria-label")).toBe("Expand site rule");
expect(toggle.querySelector("svg")).not.toBeNull();
globalThis.setSiteRuleExpandedState(rule, true);
expect(rule.classList.contains("collapsed")).toBe(false);
expect(body.style.display).toBe("block");
expect(toggle.getAttribute("aria-expanded")).toBe("true");
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: [] } });
globalThis.createSiteRule({ pattern: "youtube.com" });
const rule = document.getElementById("siteRulesContainer").lastElementChild;
const playbackOverride = rule.querySelector(".override-playback");
const playbackContainer = rule.querySelector(".site-playback-container");
const rememberSpeed = rule.querySelector(".site-rememberSpeed");
expect(playbackContainer.classList.contains("site-override-disabled")).toBe(
true
);
expect(rememberSpeed.disabled).toBe(true);
playbackOverride.checked = true;
playbackOverride.dispatchEvent(
new Event("change", {
bubbles: true
})
);
expect(playbackContainer.classList.contains("site-override-disabled")).toBe(
false
);
expect(rememberSpeed.disabled).toBe(false);
});
it("saves normalized settings and sanitized popup/site-rule controls", async () => {
const chrome = await setupOptions();
document.getElementById("rememberSpeed").checked = true;
document.getElementById("hideWithControlsTimer").value = "20";
document.getElementById("controllerOpacity").value = "0";
document.getElementById("controllerMarginTop").value = "250";
document.getElementById("controllerMarginBottom").value = "-4";
document.getElementById("enableSubtitleNudge").checked = true;
document.getElementById("subtitleNudgeInterval").value = "5";
document.getElementById("popupMatchHoverControls").checked = false;
document.getElementById("showPopupControlBar").checked = false;
globalThis.populatePopupControlBarEditor([
"rewind",
"settings",
"faster",
"faster"
]);
globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule");
rule.querySelector(".site-pattern").value = "youtube.com";
rule.querySelector(".override-playback").checked = true;
rule.querySelector(".site-rememberSpeed").checked = true;
rule.querySelector(".override-opacity").checked = true;
rule.querySelector(".site-controllerOpacity").value = "0";
rule.querySelector(".override-popup-controlbar").checked = true;
rule.querySelector(".site-showPopupControlBar").checked = false;
globalThis.populateControlBarZones(
rule.querySelector(".site-popup-cb-active"),
rule.querySelector(".site-popup-cb-available"),
["advance", "settings", "advance"],
function (id) {
return id !== "settings";
}
);
globalThis.save_options();
expect(chrome.storage.sync.remove).toHaveBeenCalled();
const savedSettings =
chrome.storage.sync.set.mock.calls[
chrome.storage.sync.set.mock.calls.length - 1
][0];
expect(savedSettings.rememberSpeed).toBe(true);
expect(savedSettings.hideWithControlsTimer).toBe(15);
expect(savedSettings.controllerOpacity).toBe(0);
expect(savedSettings.controllerMarginTop).toBe(200);
expect(savedSettings.controllerMarginBottom).toBe(0);
expect(savedSettings.subtitleNudgeInterval).toBe(10);
expect(savedSettings.showPopupControlBar).toBe(false);
expect(savedSettings.popupMatchHoverControls).toBe(false);
expect(savedSettings.popupControllerButtons).toEqual(["rewind", "faster"]);
expect(savedSettings.siteRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
pattern: "youtube.com",
rememberSpeed: true,
controllerOpacity: 0,
showPopupControlBar: false,
popupControllerButtons: ["advance"]
})
])
);
});
});
+230
View File
@@ -0,0 +1,230 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
fireDOMContentLoaded,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlFile
} = require("./helpers/extension-test-utils");
function bootOptions(options) {
const config = options || {};
loadHtmlFile("options.html");
installCommonWindowMocks();
const chrome = createChromeMock({
manifest: { version: "5.1.7.0" },
syncData: config.syncData,
localData: config.localData
});
global.chrome = chrome;
window.chrome = chrome;
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
text: () => Promise.resolve("<svg></svg>")
})
);
window.fetch = global.fetch;
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
evaluateScript("options.js");
fireDOMContentLoaded();
return chrome;
}
describe("options.js", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
delete global.fetch;
});
it("restores saved settings, bindings, site rules, and popup bar order", async () => {
bootOptions({
syncData: {
rememberSpeed: true,
forceLastSavedSpeed: true,
controllerLocation: "middle-right",
controllerOpacity: 0.75,
controllerMarginTop: 22,
controllerMarginBottom: 14,
popupMatchHoverControls: false,
controllerButtons: ["rewind", "fast", "display"],
popupControllerButtons: ["advance", "settings", "rewind", "advance"],
keyBindings: [
{ action: "display", code: "KeyV", value: 0, predefined: true },
{ action: "pause", code: "KeyQ", value: 0, predefined: false }
],
siteRules: [
{
pattern: "youtube.com",
enabled: false,
controllerMarginTop: 12,
popupControllerButtons: ["advance", "settings", "rewind"]
}
]
}
});
await flushAsyncWork(3);
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
expect(document.getElementById("rememberSpeed").checked).toBe(true);
expect(document.getElementById("forceLastSavedSpeed").checked).toBe(true);
expect(document.getElementById("controllerLocation").value).toBe(
"middle-right"
);
expect(document.getElementById("controllerOpacity").value).toBe("0.75");
expect(document.getElementById("controllerMarginTop").value).toBe("22");
expect(document.getElementById("popupMatchHoverControls").checked).toBe(false);
expect(
document.getElementById("popupCbEditorWrap").classList.contains(
"cb-editor-disabled"
)
).toBe(false);
const popupButtons = Array.from(
document.querySelectorAll("#popupControlBarActive .cb-block")
).map((block) => block.dataset.buttonId);
expect(popupButtons).toEqual(["advance", "rewind"]);
expect(
document.querySelector('.shortcut-row.customs[data-action="pause"]')
).not.toBeNull();
expect(document.querySelectorAll(".site-rule")).toHaveLength(1);
expect(document.querySelector(".site-rule .site-enabled").checked).toBe(false);
});
it("saves normalized settings and site rule overrides", async () => {
const chrome = bootOptions();
await flushAsyncWork(3);
document.getElementById("rememberSpeed").checked = true;
document.getElementById("hideWithControlsTimer").value = "99";
document.getElementById("controllerLocation").value = "bottom-left";
document.getElementById("controllerOpacity").value = "0.65";
document.getElementById("controllerMarginTop").value = "250";
document.getElementById("controllerMarginBottom").value = "-5";
document.getElementById("popupMatchHoverControls").checked = false;
document.getElementById("showPopupControlBar").checked = true;
window.populatePopupControlBarEditor(["advance", "settings", "rewind"]);
window.createSiteRule({ pattern: "youtube.com" });
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";
ruleEl.querySelector(".site-controllerMarginBottom").value = "-10";
ruleEl.querySelector(".override-autohide").checked = true;
ruleEl.querySelector(".site-hideWithControls").checked = true;
ruleEl.querySelector(".site-hideWithControlsTimer").value = "0";
ruleEl.querySelector(".override-popup-controlbar").checked = true;
ruleEl.querySelector(".site-showPopupControlBar").checked = false;
window.populateControlBarZones(
ruleEl.querySelector(".site-popup-cb-active"),
ruleEl.querySelector(".site-popup-cb-available"),
["advance", "settings", "rewind"],
function (id) {
return id !== "settings";
}
);
window.save_options();
expect(chrome.storage.sync.remove).toHaveBeenCalledWith(
[
"resetSpeed",
"speedStep",
"fastSpeed",
"rewindTime",
"advanceTime",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode",
"blacklist"
],
expect.any(Function)
);
const savedSettings = chrome.storage.sync.set.mock.calls.at(-1)[0];
expect(savedSettings.rememberSpeed).toBe(true);
expect(savedSettings.hideWithControlsTimer).toBe(15);
expect(savedSettings.controllerLocation).toBe("bottom-left");
expect(savedSettings.controllerMarginTop).toBe(200);
expect(savedSettings.controllerMarginBottom).toBe(0);
expect(savedSettings.popupControllerButtons).toEqual(["advance", "rewind"]);
expect(savedSettings.siteRules).toEqual([
{
pattern: "youtube.com",
enabled: true,
controllerLocation: "top-right",
controllerMarginTop: 200,
controllerMarginBottom: 0,
hideWithControls: true,
hideWithControlsTimer: 0.1,
showPopupControlBar: false,
popupControllerButtons: ["advance", "rewind"]
}
]);
});
it("blocks save when a site rule regex is invalid", async () => {
const chrome = bootOptions();
await flushAsyncWork(3);
window.createSiteRule({ pattern: "/[abc/" });
window.save_options();
expect(document.getElementById("status").textContent).toContain(
"Invalid site rule regex"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
it("adds shortcuts from the selector and records key input states", async () => {
bootOptions();
await flushAsyncWork(3);
const selector = document.getElementById("addShortcutSelector");
selector.value = "pause";
selector.dispatchEvent(new window.Event("change", { bubbles: true }));
const row = document.querySelector('.shortcut-row.customs[data-action="pause"]');
expect(row).not.toBeNull();
const keyInput = row.querySelector(".customKey");
keyInput.dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "q",
code: "KeyQ",
bubbles: true
})
);
expect(keyInput.vscBinding.code).toBe("KeyQ");
expect(keyInput.value).toBe("Q");
keyInput.dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
bubbles: true
})
);
expect(keyInput.vscBinding.disabled).toBe(true);
expect(selector.disabled).toBe(false);
});
});
+137
View File
@@ -0,0 +1,137 @@
import {
createChromeMock,
flushAsyncWork,
loadHtml,
loadScript,
triggerDomContentLoaded
} from "./helpers/browser.js";
async function setupPopup(overrides = {}) {
loadHtml("popup.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
loadScript("shared/site-rules.js");
loadScript("shared/popup-controls.js");
loadScript("ui-icons.js");
loadScript("popup.js");
triggerDomContentLoaded();
await flushAsyncWork();
return globalThis.chrome;
}
describe("popup UI", () => {
it("renders version, builds controls, and prefers the active frame speed", async () => {
await setupPopup({
manifestVersion: "5.1.7.0",
executeScriptResponse: [
{ speed: 1.1, preferred: false },
{ speed: 1.75, preferred: true }
]
});
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: {
enabled: true,
siteRules: [{ pattern: "example.com", enabled: false }]
}
});
expect(document.getElementById("status").innerText).toBe(
"Speeder is disabled for this site."
);
expect(document.getElementById("popupSpeed").textContent).toBe("1.00");
expect(document.getElementById("popupControlBar").style.display).toBe(
"none"
);
});
it("toggles enabled state and updates the browser action icons", async () => {
const chrome = await setupPopup();
chrome.storage.sync.set.mockClear();
chrome.browserAction.setIcon.mockClear();
document.getElementById("disable").click();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ enabled: false },
expect.any(Function)
);
expect(document.getElementById("enable").classList.contains("hide")).toBe(
false
);
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
path: {
19: "icons/icon19_disabled.png",
38: "icons/icon38_disabled.png",
48: "icons/icon48_disabled.png"
}
});
});
it("handles refresh responses for unsupported and successful pages", async () => {
vi.useFakeTimers();
const chrome = await setupPopup();
let response = null;
chrome.tabs.sendMessage.mockImplementation((tabId, message, callback) => {
if (message.action === "rescan_page") {
callback(response);
return;
}
callback({ speed: 1.25 });
});
document.getElementById("refresh").click();
expect(document.getElementById("status").innerText).toBe(
"Cannot run on this page."
);
response = { status: "complete" };
document.getElementById("refresh").click();
expect(document.getElementById("status").innerText).toBe(
"Scan complete. Closing..."
);
vi.advanceTimersByTime(500);
expect(window.close).toHaveBeenCalled();
});
it("dispatches popup control bar actions back to the active tab", async () => {
const chrome = await setupPopup();
chrome.tabs.sendMessage.mockClear();
chrome.tabs.executeScript.mockClear();
document.querySelector("#popupControlBar button").click();
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
1,
expect.objectContaining({
action: "run_action"
}),
expect.any(Function)
);
expect(chrome.tabs.executeScript).toHaveBeenCalled();
});
});
+173
View File
@@ -0,0 +1,173 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
fireDOMContentLoaded,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlFile
} = require("./helpers/extension-test-utils");
function bootPopup(options) {
const config = options || {};
loadHtmlFile("popup.html");
installCommonWindowMocks();
const chrome = createChromeMock({
manifest: { version: "9.9.9-test" },
syncData: config.syncData,
localData: config.localData,
tabsQueryResult: [
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
]
});
chrome.tabs.executeScript.mockImplementation(
config.executeScriptImpl ||
((tabId, details, callback) => {
if (callback) callback([{ speed: 1.0, preferred: true }]);
})
);
chrome.tabs.sendMessage.mockImplementation(
config.sendMessageImpl ||
((tabId, message, callback) => {
if (message.action === "get_speed") {
callback({ speed: 1.0 });
return;
}
if (message.action === "rescan_page") {
callback({ status: "complete" });
return;
}
callback({ speed: 1.0 });
})
);
global.chrome = chrome;
window.chrome = chrome;
evaluateScript("ui-icons.js");
evaluateScript("popup.js");
fireDOMContentLoaded();
return chrome;
}
describe("popup.js", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
});
it("renders the popup disabled state when a site rule disables Speeder", async () => {
bootPopup({
syncData: {
enabled: true,
siteRules: [
{
pattern: "youtube.com",
enabled: false
}
]
},
activeTab: {
id: 10,
active: true,
url: "https://www.youtube.com/watch?v=abc123"
}
});
await flushAsyncWork();
expect(document.querySelector("#app-version").textContent).toBe("9.9.9-test");
expect(document.querySelector("#status").textContent).toContain(
"disabled for this site"
);
expect(document.querySelector("#popupSpeed").textContent).toBe("1.00");
expect(document.querySelector("#popupControlBar").style.display).toBe("none");
});
it("builds sanitized popup buttons and refreshes speed after an action", async () => {
const chrome = bootPopup({
syncData: {
enabled: true,
controllerButtons: ["faster", "settings", "rewind", "faster"],
popupMatchHoverControls: 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 });
return;
}
callback({ speed: 1.0 });
});
document.dispatchEvent(new window.Event("DOMContentLoaded"));
await flushAsyncWork();
const buttons = Array.from(
document.querySelectorAll("#popupControlBar button[data-action]")
).map((button) => button.dataset.action);
expect(buttons).toEqual(["faster", "rewind"]);
expect(document.querySelector("#popupSpeed").textContent).toBe("1.50");
document.querySelector('#popupControlBar button[data-action="faster"]').click();
await flushAsyncWork();
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
99,
{ action: "run_action", actionName: "faster" },
expect.any(Function)
);
expect(document.querySelector("#popupSpeed").textContent).toBe("1.75");
});
it("toggles enablement and closes after a successful refresh", async () => {
vi.useFakeTimers();
const chrome = bootPopup({
syncData: {
enabled: false
}
});
await flushAsyncWork();
expect(document.querySelector("#enable").classList.contains("hide")).toBe(false);
expect(document.querySelector("#disable").classList.contains("hide")).toBe(true);
document.querySelector("#enable").click();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ enabled: true },
expect.any(Function)
);
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
path: {
19: "icons/icon19.png",
38: "icons/icon38.png",
48: "icons/icon48.png"
}
});
document.querySelector("#refresh").click();
expect(document.querySelector("#status").textContent).toContain("Closing");
vi.advanceTimersByTime(500);
expect(window.close).toHaveBeenCalled();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, beforeEach, vi } from "vitest";
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
if (typeof window !== "undefined") {
window.open = vi.fn();
window.close = vi.fn();
}
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
});
afterEach(() => {
vi.useRealTimers();
delete globalThis.SpeederShared;
try {
delete globalThis.restore_options;
} catch {
globalThis.restore_options = undefined;
}
if (typeof document !== "undefined") {
document.head.innerHTML = "";
document.body.innerHTML = "";
}
delete globalThis.chrome;
});
+177
View File
@@ -0,0 +1,177 @@
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", () => {
const literalRule = { pattern: "example.com/watch" };
const regexRule = { pattern: "/youtube\\.com\\/watch/i" };
expect(
siteRules.matchSiteRule("https://example.com/watch?v=1", [literalRule])
).toBe(literalRule);
expect(
siteRules.matchSiteRule("https://www.youtube.com/watch?v=2", [regexRule])
).toBe(regexRule);
expect(
siteRules.matchSiteRule("https://www.youtube.com/shorts/3", [
{ pattern: "/(/" },
regexRule
])
).toBeNull();
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: {},
faster: {},
advance: {},
display: {},
settings: {}
};
expect(
popupControls.sanitizeButtonOrder(
["rewind", "settings", "rewind", "faster", "missing"],
controllerButtonDefs,
new Set(["settings"])
)
).toEqual(["rewind", "faster"]);
expect(
popupControls.resolvePopupButtons(
{
popupMatchHoverControls: true,
controllerButtons: ["advance", "display"],
popupControllerButtons: ["rewind"]
},
{ controllerButtons: ["faster", "advance"] },
{
controllerButtonDefs,
defaultButtons: ["rewind", "display"],
excludedIds: ["settings"]
}
)
).toEqual(["faster", "advance"]);
expect(
popupControls.resolvePopupButtons(
{
popupMatchHoverControls: false,
popupControllerButtons: ["rewind", "display"]
},
{ popupControllerButtons: ["advance", "settings", "advance"] },
{
controllerButtonDefs,
defaultButtons: ["rewind", "display"],
excludedIds: ["settings"]
}
)
).toEqual(["advance"]);
});
it("normalizes controller locations and margins", () => {
expect(controllerUtils.normalizeControllerLocation("top-right")).toBe(
"top-right"
);
expect(controllerUtils.normalizeControllerLocation("bogus")).toBe(
controllerUtils.defaultControllerLocation
);
expect(controllerUtils.clampControllerMarginPx(300, 65)).toBe(200);
expect(controllerUtils.clampControllerMarginPx(-5, 65)).toBe(0);
expect(controllerUtils.getNextControllerLocation("top-left")).toBe(
"top-center"
);
});
it("infers key binding codes from legacy formats", () => {
expect(keyBindingUtils.normalizeBindingKey("a")).toBe("A");
expect(keyBindingUtils.normalizeBindingKey("Esc")).toBe("Escape");
expect(keyBindingUtils.legacyBindingKeyToCode(" ")).toBe("Space");
expect(keyBindingUtils.legacyKeyCodeToCode(90)).toBe("KeyZ");
expect(keyBindingUtils.inferBindingCode({ key: "x" }, null)).toBe("KeyX");
expect(keyBindingUtils.inferBindingCode({ keyCode: 107 }, null)).toBe(
"NumpadAdd"
);
expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65);
});
it("builds and parses import/export payloads", () => {
expect(
importExportUtils.generateBackupFilename(new Date(2026, 3, 4, 8, 9, 10))
).toBe("speeder-backup_2026-04-04_08.09.10.json");
expect(
importExportUtils.buildBackupPayload(
{ rememberSpeed: true },
{ customButtonIcons: {} },
"2026-04-04T08:09:10Z"
)
).toEqual({
version: "1.1",
exportDate: "2026-04-04T08:09:10.000Z",
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: {} }
});
expect(
importExportUtils.extractImportSettings({
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: {} }
})
).toEqual({
isWrappedBackup: true,
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: {} }
});
expect(
importExportUtils.parseImportText(
JSON.stringify({ rememberSpeed: true, keyBindings: [] })
)
).toEqual({
isWrappedBackup: false,
settings: { rememberSpeed: true, keyBindings: [] },
localSettings: null
});
expect(
importExportUtils.extractImportSettings({ enabled: true })
).toEqual({
isWrappedBackup: false,
settings: { enabled: true },
localSettings: null
});
expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe(
false
);
expect(
importExportUtils.filterLocalSettingsForExport({
customButtonIcons: { faster: { slug: "zap" } },
lucideTagsCacheV1: { "a-arrow-down": ["letter"] },
lucideTagsCacheV1At: 123
})
).toEqual({
customButtonIcons: { faster: { slug: "zap" } }
});
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* Inline SVG icons (Lucide-style strokes, compatible with https://lucide.dev — ISC license).
* Use stroke="currentColor" so buttons inherit foreground for monochrome UI.
*/
var VSC_ICON_SIZE_DEFAULT = 18;
var VSC_SVG_NS = "http://www.w3.org/2000/svg";
/** Inner SVG markup only (paths / shapes inside <svg>). */
var vscUiIconPaths = {
rewind:
'<polygon points="11 19 2 12 11 5 11 19"/><polygon points="22 19 13 12 22 5 22 19"/>',
advance:
'<polygon points="13 19 22 12 13 5 13 19"/><polygon points="2 19 11 12 2 5 2 19"/>',
reset:
'<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
slower: '<line x1="5" y1="12" x2="19" y2="12"/>',
faster:
'<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
softer:
'<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="16" y1="12" x2="22" y2="12"/>',
louder:
'<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="17" y1="9" x2="17" y2="15"/><line x1="14" y1="12" x2="20" y2="12"/>',
moreHorizontal:
'<circle cx="6" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="18" cy="12" r="1.5"/>',
chevronUp: '<path d="m18 15-6-6-6 6"/>',
display:
'<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
fast: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
settings:
'<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
pause:
'<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
muted:
'<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>',
mark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>',
jump:
'<polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/>',
nudge: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
/** Lucide check — subtitle nudge on */
subtitleNudgeOn: '<path d="M20 6 9 17l-5-5"/>',
/** Lucide x — subtitle nudge off */
subtitleNudgeOff:
'<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'
};
/**
* @param {number} [size] - width/height in px
* @returns {string} full <svg>…</svg>
*/
function vscIconSvgString(action, size) {
var inner = vscUiIconPaths[action];
if (!inner) return "";
var s = size != null ? size : VSC_ICON_SIZE_DEFAULT;
return (
'<svg xmlns="http://www.w3.org/2000/svg" width="' +
s +
'" height="' +
s +
'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
inner +
"</svg>"
);
}
function vscClearElement(el) {
if (!el) return;
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
function vscSanitizeSvgTree(svg) {
if (!svg || String(svg.tagName).toLowerCase() !== "svg") return null;
svg.querySelectorAll("script, style, foreignObject").forEach(function (n) {
n.remove();
});
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();
var val = attr.value;
if (name.indexOf("on") === 0) {
el.removeAttribute(attr.name);
continue;
}
if (
(name === "href" || name === "xlink:href") &&
/^\s*javascript:/i.test(val)
) {
el.removeAttribute(attr.name);
}
}
});
svg.setAttribute("xmlns", VSC_SVG_NS);
svg.setAttribute("aria-hidden", "true");
return svg;
}
function vscCreateSvgNode(doc, svgText) {
if (!doc || !svgText || typeof svgText !== "string") return null;
var clean = String(svgText).replace(/\0/g, "").trim();
if (!clean || !/<svg[\s>]/i.test(clean)) return null;
var parsed = new DOMParser().parseFromString(clean, "image/svg+xml");
if (parsed.querySelector("parsererror")) return null;
var svg = vscSanitizeSvgTree(parsed.querySelector("svg"));
if (!svg) return null;
return doc.importNode(svg, true);
}
function vscSetSvgContent(el, svgText) {
if (!el) return false;
vscClearElement(el);
var doc = el.ownerDocument || document;
var svg = vscCreateSvgNode(doc, svgText);
if (!svg) return false;
el.appendChild(svg);
return true;
}
function vscCreateSvgWrap(doc, svgText, className) {
if (!doc) return null;
var span = doc.createElement("span");
span.className = className || "vsc-btn-icon";
if (!vscSetSvgContent(span, svgText)) {
return null;
}
return span;
}
/**
* @param {Document} doc
* @param {string} action
* @returns {HTMLElement|null} wrapper span containing svg, or null if no icon
*/
function vscIconWrap(doc, action, size) {
var html = vscIconSvgString(action, size);
if (!html) return null;
return vscCreateSvgWrap(doc, html, "vsc-btn-icon");
}
+12
View File
@@ -0,0 +1,12 @@
const { defineConfig } = require("vitest/config");
module.exports = defineConfig({
test: {
environment: "jsdom",
clearMocks: true,
globals: true,
restoreMocks: true,
include: ["tests/**/*.test.js"],
setupFiles: ["./tests/setup.js"]
}
});