Showing preview only (484K chars total). Download the full file or copy to clipboard to get everything.
Repository: igrigorik/videospeed
Branch: master
Commit: de6d250a1bca
Files: 82
Total size: 459.0 KB
Directory structure:
gitextract_25y6uuoy/
├── .eslintrc.json
├── .gitignore
├── .pre-commit-config.yaml
├── CONTRIBUTING.md
├── LICENSE
├── PRIVACY.md
├── README.md
├── manifest.json
├── package.json
├── scripts/
│ └── build.mjs
├── src/
│ ├── background.js
│ ├── content/
│ │ ├── inject.js
│ │ ├── injection-bridge.js
│ │ └── injector-simplified.js
│ ├── core/
│ │ ├── action-handler.js
│ │ ├── settings.js
│ │ ├── state-manager.js
│ │ ├── storage-manager.js
│ │ └── video-controller.js
│ ├── entries/
│ │ ├── content-entry.js
│ │ └── inject-entry.js
│ ├── observers/
│ │ ├── media-observer.js
│ │ └── mutation-observer.js
│ ├── site-handlers/
│ │ ├── amazon-handler.js
│ │ ├── apple-handler.js
│ │ ├── base-handler.js
│ │ ├── facebook-handler.js
│ │ ├── index.js
│ │ ├── netflix-handler.js
│ │ ├── scripts/
│ │ │ └── netflix.js
│ │ └── youtube-handler.js
│ ├── styles/
│ │ └── inject.css
│ ├── ui/
│ │ ├── controls.js
│ │ ├── drag-handler.js
│ │ ├── options/
│ │ │ ├── options.css
│ │ │ ├── options.html
│ │ │ └── options.js
│ │ ├── popup/
│ │ │ ├── popup.css
│ │ │ ├── popup.html
│ │ │ └── popup.js
│ │ ├── shadow-dom.js
│ │ └── vsc-controller-element.js
│ └── utils/
│ ├── blacklist.js
│ ├── constants.js
│ ├── debug-helper.js
│ ├── dom-utils.js
│ ├── event-manager.js
│ └── logger.js
└── tests/
├── e2e/
│ ├── basic.e2e.js
│ ├── display-toggle.e2e.js
│ ├── e2e-utils.js
│ ├── icon.e2e.js
│ ├── manual-test-guide.md
│ ├── run-e2e.js
│ ├── settings-injection.e2e.js
│ ├── test-video.html
│ ├── validate-extension.js
│ └── youtube.e2e.js
├── fixtures/
│ └── test-page.html
├── helpers/
│ ├── chrome-mock.js
│ ├── module-loader.js
│ └── test-utils.js
├── integration/
│ ├── blacklist-blocking.test.js
│ ├── module-integration.test.js
│ ├── state-manager-integration.test.js
│ └── ui-to-storage-flow.test.js
├── run-tests.js
├── test-config.js
└── unit/
├── content/
│ ├── content-entry.test.js
│ ├── hydration-fix.test.js
│ └── inject.test.js
├── core/
│ ├── action-handler.test.js
│ ├── f-keys.test.js
│ ├── icon-integration.test.js
│ ├── keyboard-shortcuts-saving.test.js
│ ├── settings.test.js
│ └── video-controller.test.js
├── observers/
│ ├── audio-size-handling.test.js
│ └── mutation-observer.test.js
└── utils/
├── blacklist-regex.test.js
├── event-manager.test.js
└── recursive-shadow-dom.test.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"env": {
"browser": true,
"es2022": true,
"node": true,
"webextensions": true
},
"extends": ["eslint:recommended"],
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"globals": {
"chrome": "readonly"
},
"rules": {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"no-console": "off",
"prefer-const": "error",
"no-var": "error",
"eqeqeq": "error",
"curly": "error",
"semi": ["error", "always"],
"quotes": ["error", "single", { "avoidEscape": true }],
"no-eval": "error",
"no-implied-eval": "error",
"no-new-func": "error",
"no-script-url": "error",
"arrow-spacing": "error",
"no-duplicate-imports": "error",
"prefer-arrow-callback": "error",
"prefer-template": "error",
"no-unreachable": "error",
"no-useless-return": "error"
},
"overrides": [
{
"files": ["tests/**/*.js"],
"env": {
"jest": true
},
"rules": {
"no-unused-expressions": "off"
}
}
]
}
================================================
FILE: .gitignore
================================================
.DS_Store
.cursor
.claude
.agent
.idea
.AGENT.md
CLAUDE*.md
local
dist
tests/e2e/screenshots
node_modules
================================================
FILE: .pre-commit-config.yaml
================================================
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/prettier/prettier
rev: 1.19.1 # Use the sha or tag you want to point at
hooks:
- id: prettier
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing
Video Speed Controller is an open source project licensed under the MIT license
with many contributors. Contributions are welcome, and greatly appreciated.
If you would like to help, getting started is easy.
## Get Started
1. You must have a github account and be logged in
2. Open https://github.com/igrigorik/videospeed/
3. Fork the repo by clicking the "Fork" link on the top-right corner of the page
4. Once the fork is ready, clone to your local PC
```sh
$ git clone https://github.com/<USERNAME>/videospeed.git
Cloning into 'videospeed'...
remote: Enumerating objects: 10, done.
remote: Counting objects: 100% (10/10), done.
remote: Compressing objects: 100% (9/9), done.
remote: Total 877 (delta 3), reused 2 (delta 1), pack-reused 867
Receiving objects: 100% (877/877), 317.65 KiB | 2.17 MiB/s, done.
Resolving deltas: 100% (543/543), done.
```
5. Create a branch for your changes
```sh
$ cd videospeed
videospeed$ git checkout -b bugfix/1-fix-double-click
M .github/workflows/chrome-store-upload.yaml
M README.md
M options.js
Switched to a new branch 'bugfix/1-fix-double-click'
videospeed$
```
6. Open the code in your favorite code editor, make your changes
```sh
echo "Awesome changes" > somefile.js
git add .
```
> Important: Your commit must be formatted using
> [prettier](https://prettier.io/). If it is not it may be autoformatted for
> you or your pull request may be rejected.
7. Next, open Chrome/Brave/Chromium and enable developer mode via
`Settings > Extensions > Manage Extensions` and toggle `Developer mode` in
the top-right corner.
8. Click `Load unpacked` and browse to the folder you cloned videospeed to.
9. Try out your changes, make sure they work as expected
10. Commit and push your changes to github
```sh
git commit -m "Awesome description of some awesome changes."
git push
```
11. Open your branch up on the github website then click `New pull request` and
write up a description of your changes.
## Optional
### Run Pre-Commit Checks Locally
Installing [pre-commit](https://pre-commit.com/) is easy to do (click the link
for instructions on your platform). This repo comes with pre-commit already
configured. Doing this will ensure that your project is properly formatted and
runs some very basic tests. Once you have pre-commit installed on your system,
simply enter `pre-commit install` in your terminal in the folder to have these
checks run automatically each time you commit.
Even better, after issueing the install command you can now manually run
pre-commit checks before committing via `pre-commit run --all-files`
### Pull Upstream Changes
You should always be working with the latest version of the tool to make pull
requests easy. If you want to do this easily, just add a second remote to your
local git repo like this
`git remote add upstream https://github.com/igrigorik/videospeed.git`
Now any time you like to pull the latest version in to your local branch you can
simply issue the command `git pull upstream master`
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2014 Ilya Grigorik
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: PRIVACY.md
================================================
# Privacy Policy
Last updated: 05/01/2024
## Information Collection and Use
We do not collect any health, financial and payment, authentication, personal communications, location, web history, user activity, website content information, or other types of personally identifiable information.
## Data Usage
* Personally Identifiable Information: We do not collect any personally identifiable information.
* Health Information: We do not collect any health information.
* Financial and Payment Information: We do not collect any financial information.
* Authentication Information: We do not collect any authentication information.
* Personal Communications: We do not collect any personal communications.
* Location: We do not collect any location information.
* Web History: We do not collect any web history information.
* User Activity: We do not collect any user activity data.
* Website Content: We do not collect any content from the websites you visit.
## Data Transfer and Sale
We follow these standards to ensure the protection of your privacy:
1. We do not sell or transfer user data to third parties, outside of the approved use cases.
2. We do not use or transfer user data for purposes that are unrelated to the item's single purpose.
## Changes to This Privacy Policy
We may update our Privacy Policy from time to time. Thus, we advise you to review this page periodically for any changes. We will notify you of any changes by posting the new Privacy Policy on this page.
## Contact Us
If you have any questions or suggestions about our Privacy Policy, do not hesitate to contact us by opening a discussion the extension repository or contacting the author via information provided in GitHub profile.
================================================
FILE: README.md
================================================
# The science of accelerated playback
| Chrome Extension | Downloads | GitHub Release |
|------------------------------------------------------------------------|----------------------------------------------------------------------------------|----------------------------------------------------------------|
| [![Chrome Web Store][chrome-web-store-version]][chrome-web-store-link] | [![Chrome Web Store Users][chrome-web-store-users-badge]][chrome-web-store-link] | [![GitHub release][github-release-badge]][github-release-link] |
<!-- Badges -->
[chrome-web-store-version]: https://img.shields.io/chrome-web-store/v/nffaoalbilbmmfgbnbgppjihopabppdk?label=Chrome%20Web%20Store
[chrome-web-store-users-badge]: https://img.shields.io/chrome-web-store/users/nffaoalbilbmmfgbnbgppjihopabppdk
[github-release-badge]: https://img.shields.io/github/v/release/igrigorik/videospeed
<!-- Links -->
[chrome-web-store-link]: https://chrome.google.com/webstore/detail/poe2-trade-butler/nffaoalbilbmmfgbnbgppjihopabppdk
[github-release-link]: https://github.com/igrigorik/videospeed/releases
**TL;DR: faster playback translates to better engagement and retention.**
The 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)
(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)
to compensate for the difference.
Many viewers report that
[accelerated viewing keeps their attention longer](http://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)
if they are forced to return to normal rate of presentation.
## Faster HTML5 Video
HTML5 video provides a native API to accelerate playback of any video. The
problem is many players either hide or limit this functionality. For the best
results, playback speed adjustments should be easy and frequent to match the pace
and content being covered: we don't read at a fixed speed, and similarly, we
need an easy way to accelerate the video, slow it down, and quickly rewind the
last point to listen to it a few more times.

### _[Install Chrome Extension](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk)_
\*\* 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
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:
- **S** - decrease playback speed.
- **D** - increase playback speed.
- **R** - reset playback speed to 1.0x.
- **Z** - rewind video by 10 seconds.
- **X** - advance video by 10 seconds.
- **G** - toggle between current and user configurable preferred speed.
- **V** - show/hide the controller.
- **M** - set a marker at the current playback position.
- **J** - jump back to the previously set marker.
You can customize and reassign the default shortcut keys in the extensions
settings page as well as add additional shortcut keys to match your
preferences. As an example, you can assign multiple "preferred speed" shortcuts with different values, allowing you to quickly toggle between your most frequently used speeds. To add a new shortcut, open extension settings
and click "Add New".
After making changes or adding new settings, remember to refresh the video viewing page for them to take effect.

Unfortunately, some sites may assign other functionality to one of the shortcut keys - this is inevitable. As a workaround, the extension
listens both for lower and upper case values (i.e. you can use
`Shift-<shortcut>`) if there is other functionality assigned to the lowercase
key. This is not a perfect solution since some sites may listen to both, but it works
most of the time.
### FAQ
**The video controls are not showing up?** This extension is only compatible
with HTML5 video. If you don't see the controls showing up, chances are you are
viewing a Flash video. If you want to confirm, try right-clicking on the video
and inspect the menu: if it mentions flash, then that's the issue. That said,
most sites will fallback to HTML5 if they detect that Flash it not available.
You can try manually disabling Flash plugin in Chrome:
- In a new tab, navigate to `chrome://settings/content/flash`
- Disable "Allow sites to run Flash"
- Restart your browser and try playing your video again
**The speed controls are not showing up for local videos?** To enable playback
of local media (e.g. File > Open File), you need to grant additional permissions
to the extension.
- In a new tab, navigate to `chrome://extensions`
- Find "Video Speed Controller" extension in the list and enable "Allow access
to file URLs"
- Open a new tab and try opening a local file; the controls should show up.
### License
(MIT License) - Copyright (c) 2014 Ilya Grigorik
================================================
FILE: manifest.json
================================================
{
"name": "Video Speed Controller",
"short_name": "videospeed",
"version": "0.9.5",
"manifest_version": 3,
"minimum_chrome_version": "89",
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts",
"homepage_url": "https://github.com/igrigorik/videospeed",
"icons": {
"16": "assets/icons/icon16.png",
"48": "assets/icons/icon48.png",
"128": "assets/icons/icon128.png"
},
"permissions": [
"storage",
"activeTab"
],
"background": {
"service_worker": "background.js"
},
"options_ui": {
"page": "ui/options/options.html",
"open_in_tab": true
},
"action": {
"default_icon": {
"19": "assets/icons/icon19.png",
"38": "assets/icons/icon38.png",
"48": "assets/icons/icon48.png"
},
"default_popup": "ui/popup/popup.html"
},
"content_scripts": [
{
"all_frames": true,
"matches": [
"http://*/*",
"https://*/*",
"file:///*"
],
"match_about_blank": true,
"exclude_matches": [
"https://hangouts.google.com/*",
"https://meet.google.com/*"
],
"css": [
"styles/inject.css"
],
"js": [
"content.js"
]
}
],
"web_accessible_resources": [
{
"resources": [
"inject.js"
],
"matches": [
"http://*/*",
"https://*/*",
"file:///*"
]
}
]
}
================================================
FILE: package.json
================================================
{
"name": "video-speed-controller",
"version": "0.8.0",
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts",
"type": "module",
"scripts": {
"build": "node scripts/build.mjs",
"dev": "node scripts/build.mjs --watch",
"prebuild": "rm -rf dist",
"test": "npm run build && npm run test:unit",
"test:unit": "node tests/run-tests.js unit",
"test:integration": "node tests/run-tests.js integration",
"test:e2e": "npm run build && node tests/e2e/run-e2e.js",
"test:browser": "echo 'Open tests/fixtures/test-page.html in browser'",
"serve": "python3 -m http.server 8000",
"lint": "eslint src/**/*.js tests/**/*.js",
"lint:fix": "eslint src/**/*.js tests/**/*.js --fix",
"format": "prettier --write src/**/*.js tests/**/*.js",
"zip": "npm run build && cd dist && zip -r videospeed.zip . && cd .. && echo 'Extension packaged at dist/videospeed.zip'"
},
"devDependencies": {
"@eslint/js": "^8.57.0",
"esbuild": "^0.25.0",
"eslint": "^8.57.0",
"fs-extra": "^11.2.0",
"globals": "^13.24.0",
"jsdom": "^23.0.0",
"prettier": "^3.1.0",
"puppeteer": "^24.10.2"
},
"eslintConfig": {
"env": {
"browser": true,
"es2022": true,
"node": true,
"webextensions": true
},
"extends": [
"eslint:recommended"
],
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"globals": {
"chrome": "readonly"
},
"rules": {
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"no-console": "off",
"prefer-const": "error",
"no-var": "error",
"eqeqeq": "error",
"curly": "error",
"semi": [
"error",
"always"
],
"quotes": [
"error",
"single",
{
"avoidEscape": true
}
]
}
},
"prettier": {
"singleQuote": true,
"semi": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}
}
================================================
FILE: scripts/build.mjs
================================================
import esbuild from 'esbuild';
import process from 'process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs-extra';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const isWatch = process.argv.includes('--watch');
const common = {
bundle: true,
sourcemap: false, // set true locally if debugging
minify: false,
target: 'chrome114',
platform: 'browser',
legalComments: 'none',
format: 'iife', // preserve side-effects and simple global init without ESM runtime
define: { 'process.env.NODE_ENV': '"production"' },
};
async function copyStaticFiles() {
const rootDir = path.resolve(__dirname, '..');
const outDir = path.resolve(rootDir, 'dist');
try {
// Ensure the output directory exists and is clean
await fs.emptyDir(outDir);
// Paths to copy
const pathsToCopy = {
'manifest.json': path.join(outDir, 'manifest.json'),
'src/assets': path.join(outDir, 'assets'),
'src/ui': path.join(outDir, 'ui'),
'src/styles': path.join(outDir, 'styles'),
'LICENSE': path.join(outDir, 'LICENSE'),
'CONTRIBUTING.md': path.join(outDir, 'CONTRIBUTING.md'),
'PRIVACY.md': path.join(outDir, 'PRIVACY.md'),
'README.md': path.join(outDir, 'README.md')
};
// Perform copy operations
for (const [src, dest] of Object.entries(pathsToCopy)) {
await fs.copy(path.join(rootDir, src), dest, {
filter: (src) => !path.basename(src).endsWith('.js')
});
}
console.log('✅ Static files copied');
} catch (error) {
console.error('❌ Error copying static files:', error);
process.exit(1);
}
}
async function build() {
try {
await copyStaticFiles();
const esbuildConfig = {
...common,
entryPoints: {
'content': 'src/entries/content-entry.js',
'inject': 'src/entries/inject-entry.js',
'background': 'src/background.js',
'ui/popup/popup': 'src/ui/popup/popup.js',
'ui/options/options': 'src/ui/options/options.js'
},
outdir: 'dist',
};
if (isWatch) {
const ctx = await esbuild.context(esbuildConfig);
await ctx.watch();
console.log('🔧 Watching for changes...');
} else {
await esbuild.build(esbuildConfig);
console.log('✅ Build complete');
}
} catch (error) {
console.error('❌ Build failed:', error);
process.exit(1);
}
}
build();
================================================
FILE: src/background.js
================================================
/**
* Update extension icon based on enabled state
* @param {boolean} enabled - Whether extension is enabled
*/
async function updateIcon(enabled) {
try {
const suffix = enabled ? '' : '_disabled';
await chrome.action.setIcon({
path: {
"19": `assets/icons/icon19${suffix}.png`,
"38": `assets/icons/icon38${suffix}.png`,
"48": `assets/icons/icon48${suffix}.png`
}
});
console.log(`Icon updated: ${enabled ? 'enabled' : 'disabled'}`);
} catch (error) {
console.error('Failed to update icon:', error);
}
}
/**
* Initialize icon state from storage
*/
async function initializeIcon() {
try {
const storage = await chrome.storage.sync.get({ enabled: true });
await updateIcon(storage.enabled);
} catch (error) {
console.error('Failed to initialize icon:', error);
// Default to enabled if storage read fails
await updateIcon(true);
}
}
/**
* Migrate storage to current config version
* Removes deprecated keys from older versions
*/
async function migrateConfig() {
const DEPRECATED_KEYS = [
// Removed in v0.9.x
'speeds',
'version',
// Migrated to keyBindings array in v0.6.x
'resetSpeed',
'speedStep',
'fastSpeed',
'rewindTime',
'advanceTime',
'resetKeyCode',
'slowerKeyCode',
'fasterKeyCode',
'rewindKeyCode',
'advanceKeyCode',
'fastKeyCode',
'displayKeyCode',
];
try {
await chrome.storage.sync.remove(DEPRECATED_KEYS);
console.log('[VSC] Config migrated to current version');
} catch (error) {
console.error('[VSC] Config migration failed:', error);
}
}
/**
* Listen for storage changes (extension enabled/disabled)
*/
chrome.storage.onChanged.addListener((changes, namespace) => {
if (namespace === 'sync' && changes.enabled) {
updateIcon(changes.enabled.newValue !== false);
}
});
/**
* Handle messages from popup
*/
chrome.runtime.onMessage.addListener((message, sender) => {
if (message.type === 'EXTENSION_TOGGLE') {
// Update icon when extension is toggled via popup
updateIcon(message.enabled);
}
});
/**
* Initialize on install/update
*/
chrome.runtime.onInstalled.addListener(async () => {
console.log('Video Speed Controller installed/updated');
await migrateConfig();
await initializeIcon();
});
/**
* Initialize on startup
*/
chrome.runtime.onStartup.addListener(async () => {
console.log('Video Speed Controller started');
await initializeIcon();
});
// Initialize immediately when service worker loads
initializeIcon();
console.log('Video Speed Controller background script loaded');
================================================
FILE: src/content/inject.js
================================================
/**
* Video Speed Controller - Main Content Script
* Modular architecture using global variables loaded via script array
*/
class VideoSpeedExtension {
constructor() {
this.config = null;
this.actionHandler = null;
this.eventManager = null;
this.mutationObserver = null;
this.mediaObserver = null;
this.initialized = false;
}
/**
* Initialize the extension
*/
async initialize() {
try {
// Access global modules
this.VideoController = window.VSC.VideoController;
this.ActionHandler = window.VSC.ActionHandler;
this.EventManager = window.VSC.EventManager;
this.logger = window.VSC.logger;
this.initializeWhenReady = window.VSC.DomUtils.initializeWhenReady;
this.siteHandlerManager = window.VSC.siteHandlerManager;
this.VideoMutationObserver = window.VSC.VideoMutationObserver;
this.MediaElementObserver = window.VSC.MediaElementObserver;
this.MESSAGE_TYPES = window.VSC.Constants.MESSAGE_TYPES;
this.logger.info('Video Speed Controller starting...');
this.config = window.VSC.videoSpeedConfig;
await this.config.load();
// Initialize site handler
this.siteHandlerManager.initialize(document);
// Create action handler and event manager
this.eventManager = new this.EventManager(this.config, null);
this.actionHandler = new this.ActionHandler(this.config, this.eventManager);
this.eventManager.actionHandler = this.actionHandler; // Set circular reference
// Set up observers
this.setupObservers();
// Initialize when document is ready
this.initializeWhenReady(document, (doc) => {
this.initializeDocument(doc);
});
this.logger.info('Video Speed Controller initialized successfully');
this.initialized = true;
} catch (error) {
console.error(`❌ Failed to initialize Video Speed Controller: ${error.message}`);
console.error('📋 Full error details:', error);
console.error('🔍 Error stack:', error.stack);
}
}
/**
* Initialize for a specific document
* @param {Document} document - Document to initialize
*/
initializeDocument(document) {
try {
if (window.VSC.initialized) {
return;
}
window.VSC.initialized = true;
this.applyDomainStyles(document);
this.eventManager.setupEventListeners(document);
if (document !== window.document) {
this.setupDocumentCSS(document);
}
this.deferExpensiveOperations(document);
this.logger.debug('Document initialization completed');
} catch (error) {
this.logger.error(`Failed to initialize document: ${error.message}`);
}
}
/**
* Defer expensive operations to avoid blocking page load
* @param {Document} document - Document to defer operations for
*/
deferExpensiveOperations(document) {
// Use requestIdleCallback with a longer timeout to avoid blocking critical page load
const callback = () => {
try {
// Start mutation observer after page load is complete
if (this.mutationObserver) {
this.mutationObserver.start(document);
this.logger.debug('Mutation observer started for document');
}
// Defer media scanning to avoid blocking page load
this.deferredMediaScan(document);
} catch (error) {
this.logger.error(`Failed to complete deferred operations: ${error.message}`);
}
};
// Use requestIdleCallback if available, with reasonable timeout
if (window.requestIdleCallback) {
requestIdleCallback(callback, { timeout: 2000 });
} else {
// Fallback for browsers without requestIdleCallback
setTimeout(callback, 100);
}
}
/**
* Perform media scanning in a non-blocking way
* @param {Document} document - Document to scan
*/
deferredMediaScan(document) {
// Split media scanning into smaller chunks to avoid blocking
const performChunkedScan = () => {
try {
// Use a lighter initial scan - avoid expensive shadow DOM traversal initially
const lightMedia = this.mediaObserver.scanForMediaLight(document);
lightMedia.forEach((media) => {
this.onVideoFound(media, media.parentElement || media.parentNode);
});
this.logger.info(
`Attached controllers to ${lightMedia.length} media elements (light scan)`
);
// Schedule comprehensive scan for later if needed
if (lightMedia.length === 0) {
this.scheduleComprehensiveScan(document);
}
} catch (error) {
this.logger.error(`Failed to scan media elements: ${error.message}`);
}
};
// Use requestIdleCallback for the scan as well
if (window.requestIdleCallback) {
requestIdleCallback(performChunkedScan, { timeout: 3000 });
} else {
setTimeout(performChunkedScan, 200);
}
}
/**
* Schedule a comprehensive scan if the light scan didn't find anything
* @param {Document} document - Document to scan comprehensively
*/
scheduleComprehensiveScan(document) {
// Only do comprehensive scan if we didn't find any media with light scan
setTimeout(() => {
try {
const comprehensiveMedia = this.mediaObserver.scanAll(document);
comprehensiveMedia.forEach((media) => {
// Skip if already has controller
if (!media.vsc) {
this.onVideoFound(media, media.parentElement || media.parentNode);
}
});
this.logger.info(
`Comprehensive scan found ${comprehensiveMedia.length} additional media elements`
);
} catch (error) {
this.logger.error(`Failed comprehensive media scan: ${error.message}`);
}
}, 1000); // Wait 1 second before comprehensive scan
}
/**
* Apply domain-specific styles using CSS custom properties
* Sets CSS custom property on :root to enable CSS-based domain targeting
* @param {Document} document - Document to apply styles to
*/
applyDomainStyles(document) {
try {
const hostname = window.location.hostname;
if (document.documentElement) {
document.documentElement.style.setProperty('--vsc-domain', `"${hostname}"`);
}
} catch (error) {
this.logger.error(`Failed to apply domain styles: ${error.message}`);
}
}
/**
* Set up observers for DOM changes and video detection
*/
setupObservers() {
// Media element observer
this.mediaObserver = new this.MediaElementObserver(this.config, this.siteHandlerManager);
// Mutation observer for dynamic content
this.mutationObserver = new this.VideoMutationObserver(
this.config,
(video, parent) => this.onVideoFound(video, parent),
(video) => this.onVideoRemoved(video),
this.mediaObserver
);
}
/**
* Handle newly found video element
* @param {HTMLMediaElement} video - Video element
* @param {HTMLElement} parent - Parent element
*/
onVideoFound(video, parent) {
try {
if (this.mediaObserver && !this.mediaObserver.isValidMediaElement(video)) {
this.logger.debug('Video element is not valid for controller attachment');
return;
}
if (video.vsc) {
this.logger.debug('Video already has controller attached');
return;
}
// Check if controller should start hidden based on video visibility/size
const shouldStartHidden = this.mediaObserver
? this.mediaObserver.shouldStartHidden(video)
: false;
this.logger.debug(
'Attaching controller to new video element',
shouldStartHidden ? '(starting hidden)' : ''
);
video.vsc = new this.VideoController(
video,
parent,
this.config,
this.actionHandler,
shouldStartHidden
);
} catch (error) {
console.error('💥 Failed to attach controller to video:', error);
this.logger.error(`Failed to attach controller to video: ${error.message}`);
}
}
/**
* Handle removed video element
* @param {HTMLMediaElement} video - Video element
*/
onVideoRemoved(video) {
try {
if (video.vsc) {
this.logger.debug('Removing controller from video element');
video.vsc.remove();
}
} catch (error) {
this.logger.error(`Failed to remove video controller: ${error.message}`);
}
}
/**
* Set up CSS for iframe documents
* @param {Document} document - Document to set up CSS for
*/
setupDocumentCSS(document) {
const link = document.createElement('link');
link.href =
typeof chrome !== 'undefined' && chrome.runtime
? chrome.runtime.getURL('src/styles/inject.css')
: '/src/styles/inject.css';
link.type = 'text/css';
link.rel = 'stylesheet';
document.head.appendChild(link);
this.logger.debug('CSS injected into iframe document');
}
}
// Initialize extension and message handlers in an IIFE to avoid global scope pollution
(function () {
// Create and initialize extension instance
const extension = new VideoSpeedExtension();
// Message handler for popup communication via bridge
// Listen for messages from content script bridge
window.addEventListener('VSC_MESSAGE', (event) => {
const message = event.detail;
// Handle namespaced VSC message types
if (typeof message === 'object' && message.type && message.type.startsWith('VSC_')) {
// Use state manager for complete media element discovery (includes shadow DOM)
const videos = window.VSC.stateManager ? window.VSC.stateManager.getAllMediaElements() : [];
switch (message.type) {
case window.VSC.Constants.MESSAGE_TYPES.SET_SPEED:
if (message.payload && typeof message.payload.speed === 'number') {
const targetSpeed = message.payload.speed;
videos.forEach((video) => {
if (video.vsc) {
extension.actionHandler.adjustSpeed(video, targetSpeed);
} else {
video.playbackRate = targetSpeed;
}
});
// Log the successful operation
window.VSC.logger?.debug(`Set speed to ${targetSpeed} on ${videos.length} media elements`);
}
break;
case window.VSC.Constants.MESSAGE_TYPES.ADJUST_SPEED:
if (message.payload && typeof message.payload.delta === 'number') {
const delta = message.payload.delta;
videos.forEach((video) => {
if (video.vsc) {
extension.actionHandler.adjustSpeed(video, delta, { relative: true });
} else {
// Fallback for videos without controller
const newSpeed = Math.min(Math.max(video.playbackRate + delta, 0.07), 16);
video.playbackRate = newSpeed;
}
});
window.VSC.logger?.debug(`Adjusted speed by ${delta} on ${videos.length} media elements`);
}
break;
case window.VSC.Constants.MESSAGE_TYPES.RESET_SPEED:
videos.forEach((video) => {
if (video.vsc) {
extension.actionHandler.resetSpeed(video, 1.0);
} else {
video.playbackRate = 1.0;
}
});
window.VSC.logger?.debug(`Reset speed on ${videos.length} media elements`);
break;
case window.VSC.Constants.MESSAGE_TYPES.TOGGLE_DISPLAY:
if (extension.actionHandler) {
extension.actionHandler.runAction('display', null, null);
}
break;
}
}
});
// Prevent double injection
if (window.VSC_controller && window.VSC_controller.initialized) {
window.VSC.logger?.info('VSC already initialized, skipping re-injection');
return;
}
// Auto-initialize
extension.initialize().catch((error) => {
console.error(`Extension initialization failed: ${error.message}`);
window.VSC.logger.error(`Extension initialization failed: ${error.message}`);
});
// Export only what's needed with consistent VSC_ prefix
window.VSC_controller = extension; // The initialized instance
})();
================================================
FILE: src/content/injection-bridge.js
================================================
/**
* Content script injection helpers for bundled architecture
* Handles script injection and message bridging between contexts
*/
/**
* Inject a bundled script file into the page context
* @param {string} scriptPath - Path to the bundled script file
* @returns {Promise<void>}
*/
export async function injectScript(scriptPath) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = chrome.runtime.getURL(scriptPath);
script.onload = () => {
script.remove();
resolve();
};
script.onerror = () => {
script.remove();
reject(new Error(`Failed to load script: ${scriptPath}`));
};
(document.head || document.documentElement).appendChild(script);
});
}
/**
* Set up message bridge between content script and page context
* Handles bi-directional communication for popup and settings updates
*/
export function setupMessageBridge() {
// Listen for messages from the page context (injected script)
window.addEventListener('message', (event) => {
if (event.source !== window || !event.data?.source?.startsWith('vsc-')) {
return;
}
const { source, action, data } = event.data;
if (source === 'vsc-page') {
// Forward page messages to extension runtime
if (action === 'storage-update') {
chrome.storage.sync.set(data);
} else if (action === 'runtime-message') {
// Forward runtime messages
if (data.type !== 'VSC_STATE_UPDATE') {
chrome.runtime.sendMessage(data);
}
} else if (action === 'get-storage') {
// Page script requesting current storage
chrome.storage.sync.get(null, (items) => {
window.postMessage({
source: 'vsc-content',
action: 'storage-data',
data: items
}, '*');
});
}
}
});
// Listen for messages from popup/background
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
// Forward to page context using CustomEvent (matching what inject.js expects)
window.dispatchEvent(
new CustomEvent('VSC_MESSAGE', {
detail: request
})
);
// Handle responses if needed
if (request.action === 'get-status') {
// Wait for response from page context
const responseHandler = (event) => {
if (event.data?.source === 'vsc-page' && event.data?.action === 'status-response') {
window.removeEventListener('message', responseHandler);
sendResponse(event.data.data);
}
};
window.addEventListener('message', responseHandler);
return true; // Keep message channel open for async response
}
});
// Listen for storage changes from other extension contexts
chrome.storage.onChanged.addListener((changes, namespace) => {
if (namespace === 'sync') {
// Forward storage changes to page context
const changedData = {};
for (const [key, { newValue }] of Object.entries(changes)) {
changedData[key] = newValue;
}
window.postMessage({
source: 'vsc-content',
action: 'storage-changed',
data: changedData
}, '*');
}
});
}
================================================
FILE: src/content/injector-simplified.js
================================================
/**
* Content script injection helpers for bundled architecture
* Handles script injection and message bridging between contexts
*/
/**
* Inject a bundled script file into the page context
* @param {string} scriptPath - Path to the bundled script file
* @returns {Promise<void>}
*/
export async function injectScript(scriptPath) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = chrome.runtime.getURL(scriptPath);
script.onload = () => {
script.remove();
resolve();
};
script.onerror = () => {
script.remove();
reject(new Error(`Failed to load script: ${scriptPath}`));
};
(document.head || document.documentElement).appendChild(script);
});
}
/**
* Set up message bridge between content script and page context
* Handles bi-directional communication for popup and settings updates
*/
export function setupMessageBridge() {
// Listen for messages from the page context (injected script)
window.addEventListener('message', (event) => {
if (event.source !== window || !event.data?.source?.startsWith('vsc-')) {
return;
}
const { source, action, data } = event.data;
if (source === 'vsc-page') {
// Forward page messages to extension runtime
if (action === 'storage-update') {
chrome.storage.sync.set(data);
} else if (action === 'runtime-message') {
chrome.runtime.sendMessage(data);
} else if (action === 'get-storage') {
// Page script requesting current storage
chrome.storage.sync.get(null, (items) => {
window.postMessage({
source: 'vsc-content',
action: 'storage-data',
data: items
}, '*');
});
}
}
});
// Listen for messages from popup/background
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
// Forward to page context using CustomEvent (matching what inject.js expects)
window.dispatchEvent(
new CustomEvent('VSC_MESSAGE', {
detail: request
})
);
// Handle responses if needed
if (request.action === 'get-status') {
// Wait for response from page context
const responseHandler = (event) => {
if (event.data?.source === 'vsc-page' && event.data?.action === 'status-response') {
window.removeEventListener('message', responseHandler);
sendResponse(event.data.data);
}
};
window.addEventListener('message', responseHandler);
return true; // Keep message channel open for async response
}
});
// Listen for storage changes from other extension contexts
chrome.storage.onChanged.addListener((changes, namespace) => {
if (namespace === 'sync') {
// Forward storage changes to page context
const changedData = {};
for (const [key, { newValue }] of Object.entries(changes)) {
changedData[key] = newValue;
}
window.postMessage({
source: 'vsc-content',
action: 'storage-changed',
data: changedData
}, '*');
}
});
}
================================================
FILE: src/core/action-handler.js
================================================
/**
* Action handling system for Video Speed Controller
*
*/
window.VSC = window.VSC || {};
class ActionHandler {
constructor(config, eventManager) {
this.config = config;
this.eventManager = eventManager;
}
/**
* Execute an action on media elements
* @param {string} action - Action to perform
* @param {*} value - Action value
* @param {Event} e - Event object (optional)
*/
runAction(action, value, e) {
// Use state manager for complete media discovery (includes shadow DOM)
const mediaTags = window.VSC.stateManager ?
window.VSC.stateManager.getControlledElements() :
[]; // No fallback - state manager should always be available
// Get the controller that was used if called from a button press event
let targetController = null;
if (e) {
targetController = e.target.getRootNode().host;
}
mediaTags.forEach((v) => {
const controller = v.vsc?.div;
if (!controller) {
return;
}
// Don't change video speed if the video has a different controller
// Only apply this check for button clicks (when targetController is set)
if (e && targetController && !(targetController === controller)) {
return;
}
this.eventManager.showController(controller);
if (!v.classList.contains('vsc-cancelled')) {
this.executeAction(action, value, v, e);
}
});
}
/**
* Execute specific action on a video element
* @param {string} action - Action to perform
* @param {*} value - Action value
* @param {HTMLMediaElement} video - Video element
* @param {Event} e - Event object (optional)
* @private
*/
executeAction(action, value, video, e) {
switch (action) {
case 'rewind':
window.VSC.logger.debug('Rewind');
this.seek(video, -value);
break;
case 'advance':
window.VSC.logger.debug('Fast forward');
this.seek(video, value);
break;
case 'faster': {
window.VSC.logger.debug('Increase speed');
this.adjustSpeed(video, value, { relative: true });
break;
}
case 'slower': {
window.VSC.logger.debug('Decrease speed');
this.adjustSpeed(video, -value, { relative: true });
break;
}
case 'reset':
window.VSC.logger.debug('Reset speed');
this.resetSpeed(video, value);
break;
case 'display': {
window.VSC.logger.debug('Display action triggered');
const controller = video.vsc.div;
if (!controller) {
window.VSC.logger.error('No controller found for video');
return;
}
controller.classList.add('vsc-manual');
controller.classList.toggle('vsc-hidden');
// Clear any pending timers that might interfere with manual toggle
// This prevents delays when manually hiding/showing the controller
if (controller.blinkTimeOut !== undefined) {
clearTimeout(controller.blinkTimeOut);
controller.blinkTimeOut = undefined;
}
// Also clear EventManager timer if it exists
if (this.eventManager && this.eventManager.timer) {
clearTimeout(this.eventManager.timer);
this.eventManager.timer = null;
}
// Remove vsc-show class immediately when manually hiding
if (controller.classList.contains('vsc-hidden')) {
controller.classList.remove('vsc-show');
window.VSC.logger.debug('Removed vsc-show class for immediate manual hide');
}
break;
}
case 'blink':
window.VSC.logger.debug('Showing controller momentarily');
this.blinkController(video.vsc.div, value);
break;
case 'drag':
window.VSC.DragHandler.handleDrag(video, e);
break;
case 'fast':
this.resetSpeed(video, value);
break;
case 'pause':
this.pause(video);
break;
case 'muted':
this.muted(video);
break;
case 'louder':
this.volumeUp(video, value);
break;
case 'softer':
this.volumeDown(video, value);
break;
case 'mark':
this.setMark(video);
break;
case 'jump':
this.jumpToMark(video);
break;
case 'SET_SPEED':
window.VSC.logger.info('Setting speed to:', value);
this.adjustSpeed(video, value, { source: 'internal' });
break;
case 'ADJUST_SPEED':
window.VSC.logger.info('Adjusting speed by:', value);
this.adjustSpeed(video, value, { relative: true, source: 'internal' });
break;
case 'RESET_SPEED': {
window.VSC.logger.info('Resetting speed');
const preferredSpeed = this.config.getKeyBinding('fast') || 1.0;
this.adjustSpeed(video, preferredSpeed, { source: 'internal' });
break;
}
default:
window.VSC.logger.warn(`Unknown action: ${action}`);
}
}
/**
* Seek video by specified seconds
* @param {HTMLMediaElement} video - Video element
* @param {number} seekSeconds - Seconds to seek
*/
seek(video, seekSeconds) {
// Use site-specific seeking (handlers return true if they handle it)
window.VSC.siteHandlerManager.handleSeek(video, seekSeconds);
}
/**
* Toggle pause/play
* @param {HTMLMediaElement} video - Video element
*/
pause(video) {
if (video.paused) {
window.VSC.logger.debug('Resuming video');
video.play();
} else {
window.VSC.logger.debug('Pausing video');
video.pause();
}
}
/**
* Reset speed with memory toggle functionality
* @param {HTMLMediaElement} video - Video element
* @param {number} target - Target speed (usually 1.0)
*/
resetSpeed(video, target) {
// Show controller for visual feedback (will be shown by adjustSpeed but we can show it early)
if (video.vsc?.div && this.eventManager) {
this.eventManager.showController(video.vsc.div);
}
if (!video.vsc) {
window.VSC.logger.warn('resetSpeed called on video without controller');
return;
}
const currentSpeed = video.playbackRate;
if (currentSpeed === target) {
// At target speed - restore remembered speed if we have one, otherwise reset to target
if (video.vsc.speedBeforeReset !== null) {
window.VSC.logger.info(`Restoring remembered speed: ${video.vsc.speedBeforeReset}`);
const rememberedSpeed = video.vsc.speedBeforeReset;
video.vsc.speedBeforeReset = null; // Clear memory after use
this.adjustSpeed(video, rememberedSpeed);
} else {
window.VSC.logger.info(`Already at reset speed ${target}, no change`);
// Already at target and nothing remembered - no action needed
}
} else {
// Not at target speed - remember current and reset to target
window.VSC.logger.info(`Remembering speed ${currentSpeed} and resetting to ${target}`);
video.vsc.speedBeforeReset = currentSpeed;
this.adjustSpeed(video, target);
}
}
/**
* Toggle mute
* @param {HTMLMediaElement} video - Video element
*/
muted(video) {
video.muted = video.muted !== true;
}
/**
* Increase volume
* @param {HTMLMediaElement} video - Video element
* @param {number} value - Amount to increase
*/
volumeUp(video, value) {
video.volume = Math.min(1, (video.volume + value).toFixed(2));
}
/**
* Decrease volume
* @param {HTMLMediaElement} video - Video element
* @param {number} value - Amount to decrease
*/
volumeDown(video, value) {
video.volume = Math.max(0, (video.volume - value).toFixed(2));
}
/**
* Set time marker
* @param {HTMLMediaElement} video - Video element
*/
setMark(video) {
window.VSC.logger.debug('Adding marker');
video.vsc.mark = video.currentTime;
}
/**
* Jump to time marker
* @param {HTMLMediaElement} video - Video element
*/
jumpToMark(video) {
window.VSC.logger.debug('Recalling marker');
if (video.vsc.mark && typeof video.vsc.mark === 'number') {
video.currentTime = video.vsc.mark;
}
}
/**
* Show controller briefly
* @param {HTMLElement} controller - Controller element
* @param {number} duration - Duration in ms (default 1000)
*/
blinkController(controller, duration) {
// Don't hide audio controllers after blinking - audio elements are often invisible by design
// but should maintain visible controllers for user interaction
const isAudioController = this.isAudioController(controller);
// Always clear any existing timeout first
if (controller.blinkTimeOut !== undefined) {
clearTimeout(controller.blinkTimeOut);
controller.blinkTimeOut = undefined;
}
// Add vsc-show class to temporarily show controller
// This overrides vsc-hidden via CSS specificity
controller.classList.add('vsc-show');
window.VSC.logger.debug('Showing controller temporarily with vsc-show class');
// For audio controllers, don't set timeout to hide again
if (!isAudioController) {
controller.blinkTimeOut = setTimeout(
() => {
controller.classList.remove('vsc-show');
controller.blinkTimeOut = undefined;
window.VSC.logger.debug('Removing vsc-show class after timeout');
},
duration ? duration : 2500
);
} else {
window.VSC.logger.debug('Audio controller blink - keeping vsc-show class');
}
}
/**
* Check if controller is associated with an audio element
* @param {HTMLElement} controller - Controller element
* @returns {boolean} True if associated with audio element
* @private
*/
isAudioController(controller) {
// Find associated media element using state manager
const mediaElements = window.VSC.stateManager ?
window.VSC.stateManager.getControlledElements() : [];
for (const media of mediaElements) {
if (media.vsc && media.vsc.div === controller) {
return media.tagName === 'AUDIO';
}
}
return false;
}
/**
* Adjust video playback speed (absolute or relative)
* Simplified to use proven working logic from setSpeed method
*
* @param {HTMLMediaElement} video - Target video element
* @param {number} value - Speed value (absolute) or delta (relative)
* @param {Object} options - Configuration options
* @param {boolean} options.relative - If true, value is a delta; if false, absolute speed
* @param {string} options.source - 'internal' (user action) or 'external' (site/other)
*/
adjustSpeed(video, value, options = {}) {
return window.VSC.logger.withContext(video, () => {
const { relative = false, source = 'internal' } = options;
// DEBUG: Log all adjustSpeed calls to trace the mystery
window.VSC.logger.debug(`adjustSpeed called: value=${value}, relative=${relative}, source=${source}`);
const stack = new Error().stack;
const stackLines = stack.split('\n').slice(1, 8); // First 7 stack frames
window.VSC.logger.debug(`adjustSpeed call stack: ${stackLines.join(' -> ')}`);
// Validate input
if (!video || !video.vsc) {
window.VSC.logger.warn('adjustSpeed called on video without controller');
return;
}
if (typeof value !== 'number' || isNaN(value)) {
window.VSC.logger.warn('adjustSpeed called with invalid value:', value);
return;
}
return this._adjustSpeedInternal(video, value, options);
});
}
/**
* Internal adjustSpeed implementation (context already set)
* @private
*/
_adjustSpeedInternal(video, value, options) {
const { relative = false, source = 'internal' } = options;
// Show controller for visual feedback when speed is changed
if (video.vsc?.div && this.eventManager) {
this.eventManager.showController(video.vsc.div);
}
// Calculate target speed
let targetSpeed;
if (relative) {
// For relative changes, add to current speed
const currentSpeed = video.playbackRate < 0.1 ? 0.0 : video.playbackRate;
targetSpeed = currentSpeed + value;
window.VSC.logger.debug(`Relative speed calculation: currentSpeed=${currentSpeed} + ${value} = ${targetSpeed}`);
} else {
// For absolute changes, use value directly
targetSpeed = value;
window.VSC.logger.debug(`Absolute speed set: ${targetSpeed}`);
}
// Clamp to valid range
targetSpeed = Math.min(
Math.max(targetSpeed, window.VSC.Constants.SPEED_LIMITS.MIN),
window.VSC.Constants.SPEED_LIMITS.MAX
);
// Round to 2 decimal places to avoid floating point issues
targetSpeed = Number(targetSpeed.toFixed(2));
// Handle force mode for external changes - restore user preference
if (source === 'external' && this.config.settings.forceLastSavedSpeed) {
// In force mode, use lastSpeed instead of allowing external change
targetSpeed = this.config.settings.lastSpeed || 1.0;
window.VSC.logger.debug(`Force mode: blocking external change, restoring to ${targetSpeed}`);
}
// Use the proven setSpeed implementation with source tracking
this.setSpeed(video, targetSpeed, source);
}
/**
* Get user's preferred speed (always global lastSpeed)
* Public method for tests - matches VideoController.getTargetSpeed() logic
* @param {HTMLMediaElement} video - Video element (for API compatibility)
* @returns {number} Current preferred speed (always lastSpeed regardless of rememberSpeed setting)
*/
getPreferredSpeed(video) {
return this.config.settings.lastSpeed || 1.0;
}
/**
* Set video playback speed with complete state management
* Unified implementation with all functionality - no fragmented logic
* @param {HTMLMediaElement} video - Video element
* @param {number} speed - Target speed
* @param {string} source - Change source: 'internal' (user/extension) or 'external' (site)
*/
setSpeed(video, speed, source = 'internal') {
const speedValue = speed.toFixed(2);
const numericSpeed = Number(speedValue);
// 1. Set the actual playback rate
video.playbackRate = numericSpeed;
// 2. Always dispatch synthetic event with source tracking
// This allows EventManager to distinguish our changes from external ones
video.dispatchEvent(
new CustomEvent('ratechange', {
bubbles: true,
composed: true,
detail: {
origin: 'videoSpeed',
speed: speedValue,
source: source
},
})
);
// 3. Update UI indicator
const speedIndicator = video.vsc?.speedIndicator;
if (!speedIndicator) {
window.VSC.logger.warn(
'Cannot update speed indicator: video controller UI not fully initialized'
);
return;
}
speedIndicator.textContent = numericSpeed.toFixed(2);
// 4. Always update page-scoped speed preference
window.VSC.logger.debug(`Updating config.settings.lastSpeed from ${this.config.settings.lastSpeed} to ${numericSpeed}`);
this.config.settings.lastSpeed = numericSpeed;
// 5. Save to storage ONLY if rememberSpeed is enabled for cross-session persistence
if (this.config.settings.rememberSpeed) {
window.VSC.logger.debug(`Saving lastSpeed ${numericSpeed} to Chrome storage`);
this.config.save({
lastSpeed: this.config.settings.lastSpeed,
});
} else {
window.VSC.logger.debug('NOT saving to storage - rememberSpeed is false');
}
// 6. Show controller briefly for visual feedback
if (video.vsc?.div) {
this.blinkController(video.vsc.div);
}
// 7. Refresh cooldown to prevent rapid changes
if (this.eventManager) {
this.eventManager.refreshCoolDown();
}
}
}
// Create singleton instance
window.VSC.ActionHandler = ActionHandler;
================================================
FILE: src/core/settings.js
================================================
/**
* Settings management for Video Speed Controller
*/
window.VSC = window.VSC || {};
if (!window.VSC.VideoSpeedConfig) {
class VideoSpeedConfig {
constructor() {
this.settings = { ...window.VSC.Constants.DEFAULT_SETTINGS };
this.pendingSave = null;
this.saveTimer = null;
this.SAVE_DELAY = 1000; // 1 second
}
/**
* Load settings from Chrome storage or pre-injected settings
* @returns {Promise<Object>} Loaded settings
*/
async load() {
try {
// Use StorageManager which handles both contexts automatically
const storage = await window.VSC.StorageManager.get(window.VSC.Constants.DEFAULT_SETTINGS);
// Handle key bindings migration/initialization
this.settings.keyBindings =
storage.keyBindings || window.VSC.Constants.DEFAULT_SETTINGS.keyBindings;
if (!storage.keyBindings || storage.keyBindings.length === 0) {
window.VSC.logger.info('First initialization - setting up default key bindings');
this.settings.keyBindings = [...window.VSC.Constants.DEFAULT_SETTINGS.keyBindings];
await this.save({ keyBindings: this.settings.keyBindings });
}
// Apply loaded settings
this.settings.lastSpeed = Number(storage.lastSpeed);
this.settings.rememberSpeed = Boolean(storage.rememberSpeed);
this.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed);
this.settings.audioBoolean = Boolean(storage.audioBoolean);
this.settings.startHidden = Boolean(storage.startHidden);
this.settings.controllerOpacity = Number(storage.controllerOpacity);
this.settings.controllerButtonSize = Number(storage.controllerButtonSize);
this.settings.logLevel = Number(
storage.logLevel || window.VSC.Constants.DEFAULT_SETTINGS.logLevel
);
// Ensure display binding exists (for upgrades)
this.ensureDisplayBinding();
// Update logger verbosity
window.VSC.logger.setVerbosity(this.settings.logLevel);
window.VSC.logger.info('Settings loaded successfully');
return this.settings;
} catch (error) {
window.VSC.logger.error(`Failed to load settings: ${error.message}`);
return window.VSC.Constants.DEFAULT_SETTINGS;
}
}
/**
* Save settings to Chrome storage
* @param {Object} newSettings - Settings to save
* @returns {Promise<void>}
*/
async save(newSettings = {}) {
try {
// Update in-memory settings immediately
this.settings = { ...this.settings, ...newSettings };
// Check if this is a speed-only update that should be debounced
const keys = Object.keys(newSettings);
if (keys.length === 1 && keys[0] === 'lastSpeed') {
// Debounce speed saves
this.pendingSave = newSettings.lastSpeed;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
}
this.saveTimer = setTimeout(async () => {
const speedToSave = this.pendingSave;
this.pendingSave = null;
this.saveTimer = null;
await window.VSC.StorageManager.set({ ...this.settings, lastSpeed: speedToSave });
window.VSC.logger.info('Debounced speed setting saved successfully');
}, this.SAVE_DELAY);
return;
}
// Immediate save for all other settings
await window.VSC.StorageManager.set(this.settings);
// Update logger verbosity if logLevel was changed
if (newSettings.logLevel !== undefined) {
window.VSC.logger.setVerbosity(this.settings.logLevel);
}
window.VSC.logger.info('Settings saved successfully');
} catch (error) {
window.VSC.logger.error(`Failed to save settings: ${error.message}`);
}
}
/**
* Get a specific key binding
* @param {string} action - Action name
* @param {string} property - Property to get (default: 'value')
* @returns {*} Key binding property value
*/
getKeyBinding(action, property = 'value') {
try {
const binding = this.settings.keyBindings.find((item) => item.action === action);
return binding ? binding[property] : false;
} catch (e) {
window.VSC.logger.error(`Failed to get key binding for ${action}: ${e.message}`);
return false;
}
}
/**
* Set a key binding value with validation
* @param {string} action - Action name
* @param {*} value - Value to set
*/
setKeyBinding(action, value) {
try {
const binding = this.settings.keyBindings.find((item) => item.action === action);
if (!binding) {
window.VSC.logger.warn(`No key binding found for action: ${action}`);
return;
}
// Validate speed-related values to prevent corruption
if (['reset', 'fast', 'slower', 'faster'].includes(action)) {
if (typeof value !== 'number' || isNaN(value)) {
window.VSC.logger.warn(`Invalid numeric value for ${action}: ${value}`);
return;
}
}
binding.value = value;
window.VSC.logger.debug(`Updated key binding ${action} to ${value}`);
} catch (e) {
window.VSC.logger.error(`Failed to set key binding for ${action}: ${e.message}`);
}
}
/**
* Ensure display binding exists in key bindings
* @private
*/
ensureDisplayBinding() {
if (this.settings.keyBindings.filter((x) => x.action === 'display').length === 0) {
this.settings.keyBindings.push({
action: 'display',
key: 86, // V
value: 0,
force: false,
predefined: true,
});
}
}
}
// Create singleton instance
window.VSC.videoSpeedConfig = new VideoSpeedConfig();
// Export constructor for testing
window.VSC.VideoSpeedConfig = VideoSpeedConfig;
}
================================================
FILE: src/core/state-manager.js
================================================
/**
* Video Speed Controller State Manager
* Tracks media elements for popup and keyboard commands.
*/
window.VSC = window.VSC || {};
class VSCStateManager {
constructor() {
// Map of controllerId → controller instance
this.controllers = new Map();
window.VSC.logger?.debug('VSCStateManager initialized');
}
/**
* Register a new controller
* @param {VideoController} controller - Controller instance to register
*/
registerController(controller) {
if (!controller || !controller.controllerId) {
window.VSC.logger?.warn('Invalid controller registration attempt');
return;
}
// Store controller info for compatibility with tests
const controllerInfo = {
controller: controller,
element: controller.video,
tagName: controller.video?.tagName,
videoSrc: controller.video?.src || controller.video?.currentSrc,
created: Date.now()
};
this.controllers.set(controller.controllerId, controllerInfo);
window.VSC.logger?.debug(`Controller registered: ${controller.controllerId}`);
}
/**
* Unregister a controller
* @param {string} controllerId - ID of controller to unregister
*/
unregisterController(controllerId) {
if (this.controllers.has(controllerId)) {
this.controllers.delete(controllerId);
window.VSC.logger?.debug(`Controller unregistered: ${controllerId}`);
}
}
/**
* Get all registered media elements
* @returns {Array<HTMLMediaElement>} Array of media elements
*/
getAllMediaElements() {
const elements = [];
// Clean up disconnected controllers while iterating
for (const [id, info] of this.controllers) {
const video = info.controller?.video || info.element;
if (video && video.isConnected) {
elements.push(video);
} else {
// Remove disconnected controller
this.controllers.delete(id);
}
}
return elements;
}
/**
* Get a media element by controller ID
* @param {string} controllerId - Controller ID
* @returns {HTMLMediaElement|null} Media element or null
*/
getMediaByControllerId(controllerId) {
const info = this.controllers.get(controllerId);
return info?.controller?.video || info?.element || null;
}
/**
* Get the first available media element
* @returns {HTMLMediaElement|null} First media element or null
*/
getFirstMedia() {
const elements = this.getAllMediaElements();
return elements[0] || null;
}
/**
* Check if any controllers are registered
* @returns {boolean} True if controllers exist
*/
hasControllers() {
return this.controllers.size > 0;
}
/**
* Compatibility method - same as unregisterController
* @param {string} controllerId - ID of controller to remove
*/
removeController(controllerId) {
this.unregisterController(controllerId);
}
/**
* Compatibility method - same as getAllMediaElements
* @returns {Array<HTMLMediaElement>} Array of media elements
*/
getControlledElements() {
return this.getAllMediaElements();
}
}
// Create singleton instance
window.VSC.StateManager = VSCStateManager;
window.VSC.stateManager = new VSCStateManager();
window.VSC.logger?.info('State Manager module loaded');
================================================
FILE: src/core/storage-manager.js
================================================
/**
* Chrome storage management utilities
* Handles storage access in both content script and page contexts
*/
window.VSC = window.VSC || {};
if (!window.VSC.StorageManager) {
class StorageManager {
static errorCallback = null;
/**
* Register error callback for monitoring storage failures
* @param {Function} callback - Callback function for errors
*/
static onError(callback) {
this.errorCallback = callback;
}
/**
* Get settings from Chrome storage or pre-injected settings
* @param {Object} defaults - Default values
* @returns {Promise<Object>} Storage data
*/
static async get(defaults = {}) {
// Check if Chrome APIs are available (content script context)
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
return new Promise((resolve) => {
chrome.storage.sync.get(defaults, (storage) => {
window.VSC.logger.debug('Retrieved settings from chrome.storage');
resolve(storage);
});
});
} else {
// Page context - read settings from DOM bridge (content script can't share JS objects directly)
if (!window.VSC_settings) {
const settingsElement = document.getElementById('vsc-settings-data');
if (settingsElement && settingsElement.textContent) {
try {
window.VSC_settings = JSON.parse(settingsElement.textContent);
window.VSC.logger.debug('Loaded settings from script element');
// Clean up the element after reading
settingsElement.remove();
} catch (e) {
window.VSC.logger.error('Failed to parse settings from script element:', e);
}
}
}
if (window.VSC_settings) {
// Use the loaded settings
window.VSC.logger.debug('Using VSC_settings');
return Promise.resolve({ ...defaults, ...window.VSC_settings });
} else {
// Fallback to defaults if no settings available
window.VSC.logger.debug('No settings available, using defaults');
return Promise.resolve(defaults);
}
}
}
/**
* Set settings in Chrome storage
* @param {Object} data - Data to store
* @returns {Promise<void>}
*/
static async set(data) {
// Check if Chrome APIs are available (content script context)
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
return new Promise((resolve, reject) => {
chrome.storage.sync.set(data, () => {
if (chrome.runtime.lastError) {
const error = new Error(`Storage failed: ${chrome.runtime.lastError.message}`);
console.error('Chrome storage save failed:', chrome.runtime.lastError);
// Call error callback if registered (for monitoring/telemetry)
if (this.errorCallback) {
this.errorCallback(error, data);
}
reject(error);
return;
}
window.VSC.logger.debug('Settings saved to chrome.storage');
resolve();
});
});
} else {
// Page context - send save request to content script via message bridge
window.VSC.logger.debug('Sending storage update to content script');
// Post message to content script
window.postMessage({
source: 'vsc-page',
action: 'storage-update',
data: data
}, '*');
// Update local settings cache
window.VSC_settings = { ...window.VSC_settings, ...data };
return Promise.resolve();
}
}
/**
* Remove keys from Chrome storage
* @param {Array<string>} keys - Keys to remove
* @returns {Promise<void>}
*/
static async remove(keys) {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
return new Promise((resolve, reject) => {
chrome.storage.sync.remove(keys, () => {
if (chrome.runtime.lastError) {
const error = new Error(`Storage remove failed: ${chrome.runtime.lastError.message}`);
console.error('Chrome storage remove failed:', chrome.runtime.lastError);
// Call error callback if registered (for monitoring/telemetry)
if (this.errorCallback) {
this.errorCallback(error, { removedKeys: keys });
}
reject(error);
return;
}
window.VSC.logger.debug('Keys removed from storage');
resolve();
});
});
} else {
// Page context - update local cache
if (window.VSC_settings) {
keys.forEach(key => delete window.VSC_settings[key]);
}
return Promise.resolve();
}
}
/**
* Clear all Chrome storage
* @returns {Promise<void>}
*/
static async clear() {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
return new Promise((resolve, reject) => {
chrome.storage.sync.clear(() => {
if (chrome.runtime.lastError) {
const error = new Error(`Storage clear failed: ${chrome.runtime.lastError.message}`);
console.error('Chrome storage clear failed:', chrome.runtime.lastError);
// Call error callback if registered (for monitoring/telemetry)
if (this.errorCallback) {
this.errorCallback(error, { operation: 'clear' });
}
reject(error);
return;
}
window.VSC.logger.debug('Storage cleared');
resolve();
});
});
} else {
// Page context - clear local cache
window.VSC_settings = {};
return Promise.resolve();
}
}
/**
* Listen for storage changes
* @param {Function} callback - Callback function for changes
*/
static onChanged(callback) {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.onChanged) {
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === 'sync') {
callback(changes);
}
});
} else {
// Page context - listen for storage changes from content script
window.addEventListener('message', (event) => {
if (event.data?.source === 'vsc-content' && event.data?.action === 'storage-changed') {
// Convert to chrome.storage.onChanged format
const changes = {};
for (const [key, value] of Object.entries(event.data.data)) {
changes[key] = { newValue: value, oldValue: window.VSC_settings?.[key] };
}
// Update local cache
window.VSC_settings = { ...window.VSC_settings, ...event.data.data };
callback(changes);
}
});
}
}
}
window.VSC.StorageManager = StorageManager;
}
================================================
FILE: src/core/video-controller.js
================================================
/**
* Video Controller class for managing individual video elements
*
*/
window.VSC = window.VSC || {};
class VideoController {
constructor(target, parent, config, actionHandler, shouldStartHidden = false) {
// Return existing controller if already attached
if (target.vsc) {
return target.vsc;
}
this.video = target;
this.parent = target.parentElement || parent;
this.config = config;
this.actionHandler = actionHandler;
this.controlsManager = new window.VSC.ControlsManager(actionHandler, config);
this.shouldStartHidden = shouldStartHidden;
// Generate unique controller ID for badge tracking
this.controllerId = this.generateControllerId(target);
// Transient reset memory (not persisted, instance-specific)
this.speedBeforeReset = null;
// Attach controller to video element first (needed for adjustSpeed)
target.vsc = this;
// Register with state manager immediately after controller is attached
if (window.VSC.stateManager) {
window.VSC.stateManager.registerController(this);
} else {
window.VSC.logger.error('StateManager not available during VideoController initialization');
}
// Initialize speed
this.initializeSpeed();
// Create UI
this.div = this.initializeControls();
// Set up event handlers
this.setupEventHandlers();
// Set up mutation observer for src changes
this.setupMutationObserver();
window.VSC.logger.info('VideoController initialized for video element');
}
/**
* Initialize video speed based on settings
* @private
*/
initializeSpeed() {
const targetSpeed = this.getTargetSpeed();
window.VSC.logger.debug(`Setting initial playbackRate to: ${targetSpeed}`);
// Use adjustSpeed for initial speed setting to ensure consistency
if (this.actionHandler && targetSpeed !== this.video.playbackRate) {
window.VSC.logger.debug('Setting initial speed via adjustSpeed');
this.actionHandler.adjustSpeed(this.video, targetSpeed, { source: 'internal' });
}
}
/**
* Get target speed based on rememberSpeed setting and update reset binding
* @param {HTMLMediaElement} media - Optional media element (defaults to this.video)
* @returns {number} Target speed
* @private
*/
getTargetSpeed(media = this.video) {
// Always start with current preferred speed (lastSpeed)
// The difference is whether changes get saved back to lastSpeed
const targetSpeed = this.config.settings.lastSpeed || 1.0;
if (this.config.settings.rememberSpeed) {
window.VSC.logger.debug(`Remember mode: using lastSpeed ${targetSpeed} (changes will be saved)`);
} else {
window.VSC.logger.debug(`Non-persistent mode: using lastSpeed ${targetSpeed} (changes won't be saved)`);
}
return targetSpeed;
}
/**
* Initialize video controller UI
* @returns {HTMLElement} Controller wrapper element
* @private
*/
initializeControls() {
window.VSC.logger.debug('initializeControls Begin');
const document = this.video.ownerDocument;
const speed = window.VSC.Constants.formatSpeed(this.video.playbackRate);
const position = window.VSC.ShadowDOMManager.calculatePosition(this.video);
window.VSC.logger.debug(`Speed variable set to: ${speed}`);
// Create custom element wrapper to avoid CSS conflicts
const wrapper = document.createElement('vsc-controller');
// Apply all CSS classes at once to prevent race condition flash
const cssClasses = ['vsc-controller'];
// Only hide controller if video has no source AND is not ready/functional
// This prevents hiding controllers for live streams or dynamically loaded videos
if (!this.video.currentSrc && !this.video.src && this.video.readyState < 2) {
cssClasses.push('vsc-nosource');
}
if (this.config.settings.startHidden || this.shouldStartHidden) {
cssClasses.push('vsc-hidden');
window.VSC.logger.debug('Starting controller hidden');
}
// When startHidden=false, use natural visibility (no special class needed)
// Apply all classes at once to prevent visible flash
wrapper.className = cssClasses.join(' ');
// Set positioning styles with calculated position
// Only use positioning styles - rely on CSS classes for visibility
const styleText = `
position: absolute !important;
z-index: 9999999 !important;
top: ${position.top};
left: ${position.left};
`;
wrapper.style.cssText = styleText;
// Create shadow DOM with relative positioning inside shadow root
const shadow = window.VSC.ShadowDOMManager.createShadowDOM(wrapper, {
top: '0px', // Position relative to shadow root since wrapper is already positioned
left: '0px', // Position relative to shadow root since wrapper is already positioned
speed: speed,
opacity: this.config.settings.controllerOpacity,
buttonSize: this.config.settings.controllerButtonSize,
});
// Set up control events
this.controlsManager.setupControlEvents(shadow, this.video);
// Store speed indicator reference
this.speedIndicator = window.VSC.ShadowDOMManager.getSpeedIndicator(shadow);
// Insert into DOM based on site-specific rules
this.insertIntoDOM(document, wrapper);
window.VSC.logger.debug('initializeControls End');
return wrapper;
}
/**
* Insert controller into DOM with site-specific positioning
* @param {Document} document - Document object
* @param {HTMLElement} wrapper - Wrapper element to insert
* @private
*/
insertIntoDOM(document, wrapper) {
const fragment = document.createDocumentFragment();
fragment.appendChild(wrapper);
// Get site-specific positioning information
const positioning = window.VSC.siteHandlerManager.getControllerPosition(
this.parent,
this.video
);
switch (positioning.insertionMethod) {
case 'beforeParent':
positioning.insertionPoint.parentElement.insertBefore(fragment, positioning.insertionPoint);
break;
case 'afterParent':
positioning.insertionPoint.parentElement.insertBefore(
fragment,
positioning.insertionPoint.nextSibling
);
break;
case 'firstChild':
default:
positioning.insertionPoint.insertBefore(fragment, positioning.insertionPoint.firstChild);
break;
}
window.VSC.logger.debug(`Controller inserted using ${positioning.insertionMethod} method`);
}
/**
* Set up event handlers for media events
* @private
*/
setupEventHandlers() {
const mediaEventAction = (event) => {
const targetSpeed = this.getTargetSpeed(event.target);
window.VSC.logger.info(`Media event ${event.type}: restoring speed to ${targetSpeed}`);
this.actionHandler.adjustSpeed(event.target, targetSpeed, { source: 'internal' });
};
// Bind event handlers
this.handlePlay = mediaEventAction.bind(this);
this.handleSeek = mediaEventAction.bind(this);
// Add essential event listeners for speed restoration
this.video.addEventListener('play', this.handlePlay);
this.video.addEventListener('seeked', this.handleSeek);
window.VSC.logger.debug(
'Added essential media event handlers: play, seeked'
);
}
/**
* Set up mutation observer for src attribute changes
* @private
*/
setupMutationObserver() {
this.targetObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === 'attributes' &&
(mutation.attributeName === 'src' || mutation.attributeName === 'currentSrc')
) {
window.VSC.logger.debug('Mutation of A/V element detected');
const controller = this.div;
if (!mutation.target.src && !mutation.target.currentSrc) {
controller.classList.add('vsc-nosource');
} else {
controller.classList.remove('vsc-nosource');
}
}
});
});
this.targetObserver.observe(this.video, {
attributeFilter: ['src', 'currentSrc'],
});
}
/**
* Remove controller and clean up
*/
remove() {
window.VSC.logger.debug('Removing VideoController');
// Remove DOM element
if (this.div && this.div.parentNode) {
this.div.remove();
}
// Remove event listeners
if (this.handlePlay) {
this.video.removeEventListener('play', this.handlePlay);
}
if (this.handleSeek) {
this.video.removeEventListener('seeked', this.handleSeek);
}
// Disconnect mutation observer
if (this.targetObserver) {
this.targetObserver.disconnect();
}
// Remove from state manager
if (window.VSC.stateManager) {
window.VSC.stateManager.removeController(this.controllerId);
}
// Remove reference from video element
delete this.video.vsc;
window.VSC.logger.debug('VideoController removed successfully');
}
/**
* Generate unique controller ID for badge tracking
* @param {HTMLElement} target - Video/audio element
* @returns {string} Unique controller ID
* @private
*/
generateControllerId(target) {
const timestamp = Date.now();
const src = target.currentSrc || target.src || 'no-src';
const tagName = target.tagName.toLowerCase();
// Create a simple hash from src for uniqueness
const srcHash = src.split('').reduce((hash, char) => {
hash = (hash << 5) - hash + char.charCodeAt(0);
return hash & hash; // Convert to 32-bit integer
}, 0);
const random = Math.floor(Math.random() * 1000);
return `${tagName}-${Math.abs(srcHash)}-${timestamp}-${random}`;
}
/**
* Check if the video element is currently visible
* @returns {boolean} True if video is visible
*/
isVideoVisible() {
// Check if video is still connected to DOM
if (!this.video.isConnected) {
return false;
}
// Check computed style for visibility
const style = window.getComputedStyle(this.video);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
// Check if video has reasonable dimensions
const rect = this.video.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return false;
}
return true;
}
/**
* Update controller visibility based on video visibility
* Called when video visibility changes
*/
updateVisibility() {
const isVisible = this.isVideoVisible();
const isCurrentlyHidden = this.div.classList.contains('vsc-hidden');
// Special handling for audio elements - don't hide controllers for functional audio
if (this.video.tagName === 'AUDIO') {
// For audio, only hide if manually hidden or if audio support is disabled
if (!this.config.settings.audioBoolean && !isCurrentlyHidden) {
this.div.classList.add('vsc-hidden');
window.VSC.logger.debug('Hiding audio controller - audio support disabled');
} else if (
this.config.settings.audioBoolean &&
isCurrentlyHidden &&
!this.div.classList.contains('vsc-manual')
) {
// Show audio controller if audio support is enabled and not manually hidden
this.div.classList.remove('vsc-hidden');
window.VSC.logger.debug('Showing audio controller - audio support enabled');
}
return;
}
// Original logic for video elements
if (
isVisible &&
isCurrentlyHidden &&
!this.div.classList.contains('vsc-manual') &&
!this.config.settings.startHidden
) {
// Video became visible and controller is hidden (but not manually hidden and not set to start hidden)
this.div.classList.remove('vsc-hidden');
window.VSC.logger.debug('Showing controller - video became visible');
} else if (!isVisible && !isCurrentlyHidden) {
// Video became invisible and controller is visible
this.div.classList.add('vsc-hidden');
window.VSC.logger.debug('Hiding controller - video became invisible');
}
}
}
// Create singleton instance
window.VSC.VideoController = VideoController;
// Global variables available for both browser and testing
================================================
FILE: src/entries/content-entry.js
================================================
/**
* Content script entry point - handles Chrome API access and page injection
* This runs in the content script context with access to chrome.* APIs
*/
import { injectScript, setupMessageBridge } from '../content/injection-bridge.js';
import { isBlacklisted } from '../utils/blacklist.js';
async function init() {
try {
const settings = await chrome.storage.sync.get(null);
// Early exit if extension is disabled
if (settings.enabled === false) {
console.debug('[VSC] Extension disabled');
return;
}
// Early exit if site is blacklisted
if (isBlacklisted(settings.blacklist, location.href)) {
console.debug('[VSC] Site blacklisted');
return;
}
delete settings.blacklist;
delete settings.enabled;
// Bridge settings to page context via DOM (only synchronous path between Chrome's isolated worlds)
// Script elements with type="application/json" are inert, avoiding site interference and CSP issues
const settingsElement = document.createElement('script');
settingsElement.id = 'vsc-settings-data';
settingsElement.type = 'application/json';
settingsElement.textContent = JSON.stringify(settings);
(document.head || document.documentElement).appendChild(settingsElement);
// Inject the bundled page script containing all VSC modules
await injectScript('inject.js');
// Set up bi-directional message bridge for popup ↔ page communication
setupMessageBridge();
console.debug('[VSC] Content script initialized');
} catch (error) {
console.error('[VSC] Failed to initialize:', error);
}
}
// Initialize on DOM ready or immediately if already loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
================================================
FILE: src/entries/inject-entry.js
================================================
/**
* Page context entry point - bundles all VSC modules for injection
* This runs in the page context with access to page APIs but not chrome.* APIs
* All modules are loaded in dependency order to ensure proper initialization
*/
// Core utilities and constants - must load first
import '../utils/constants.js';
import '../utils/logger.js';
import '../utils/debug-helper.js';
import '../utils/dom-utils.js';
import '../utils/event-manager.js';
// Storage and settings - depends on utils
import '../core/storage-manager.js';
import '../core/settings.js';
// State management - depends on utils and logger
import '../core/state-manager.js';
// Observers - depends on utils and settings
import '../observers/media-observer.js';
import '../observers/mutation-observer.js';
// Core functionality - depends on settings and observers
import '../core/action-handler.js';
import '../core/video-controller.js';
// UI components - depends on core functionality
import '../ui/controls.js';
import '../ui/drag-handler.js';
import '../ui/shadow-dom.js';
import '../ui/vsc-controller-element.js';
// Site-specific handlers - depends on core
import '../site-handlers/base-handler.js';
import '../site-handlers/netflix-handler.js';
import '../site-handlers/youtube-handler.js';
import '../site-handlers/facebook-handler.js';
import '../site-handlers/amazon-handler.js';
import '../site-handlers/apple-handler.js';
import '../site-handlers/index.js';
// Netflix-specific script
import '../site-handlers/scripts/netflix.js';
// Main initialization - must be last
import '../content/inject.js';
// The modules above populate window.VSC namespace and window.VSC_controller
// No additional exports needed here - side effects handle initialization
================================================
FILE: src/observers/media-observer.js
================================================
/**
* Media element observer for finding and tracking video/audio elements
*/
window.VSC = window.VSC || {};
class MediaElementObserver {
constructor(config, siteHandler) {
this.config = config;
this.siteHandler = siteHandler;
}
/**
* Scan document for existing media elements
* @param {Document} document - Document to scan
* @returns {Array<HTMLMediaElement>} Found media elements
*/
scanForMedia(document) {
const mediaElements = [];
const audioEnabled = this.config.settings.audioBoolean;
const mediaTagSelector = audioEnabled ? 'video,audio' : 'video';
// Find regular media elements
const regularMedia = Array.from(document.querySelectorAll(mediaTagSelector));
mediaElements.push(...regularMedia);
// Find media elements in shadow DOMs recursively
function findShadowMedia(root, selector) {
const results = [];
// Add any matching elements in current shadow root
results.push(...root.querySelectorAll(selector));
// Recursively check all elements with shadow roots
root.querySelectorAll('*').forEach((element) => {
if (element.shadowRoot) {
results.push(...findShadowMedia(element.shadowRoot, selector));
}
});
return results;
}
const shadowMedia = findShadowMedia(document, mediaTagSelector);
mediaElements.push(...shadowMedia);
// Find site-specific media elements
const siteSpecificMedia = this.siteHandler.detectSpecialVideos(document);
mediaElements.push(...siteSpecificMedia);
// Filter out ignored videos
const filteredMedia = mediaElements.filter((media) => {
return !this.siteHandler.shouldIgnoreVideo(media);
});
window.VSC.logger.info(
`Found ${filteredMedia.length} media elements (${mediaElements.length} total, ${mediaElements.length - filteredMedia.length} filtered out)`
);
return filteredMedia;
}
/**
* Lightweight scan that avoids expensive shadow DOM traversal
* Used during initial load to avoid blocking page performance
* @param {Document} document - Document to scan
* @returns {Array<HTMLMediaElement>} Found media elements
*/
scanForMediaLight(document) {
const mediaElements = [];
const audioEnabled = this.config.settings.audioBoolean;
const mediaTagSelector = audioEnabled ? 'video,audio' : 'video';
try {
// Only do basic DOM query, no shadow DOM traversal
const regularMedia = Array.from(document.querySelectorAll(mediaTagSelector));
mediaElements.push(...regularMedia);
// Find site-specific media elements (usually lightweight)
const siteSpecificMedia = this.siteHandler.detectSpecialVideos(document);
mediaElements.push(...siteSpecificMedia);
// Filter out ignored videos
const filteredMedia = mediaElements.filter((media) => {
return !this.siteHandler.shouldIgnoreVideo(media);
});
window.VSC.logger.info(
`Light scan found ${filteredMedia.length} media elements (${mediaElements.length} total, ${mediaElements.length - filteredMedia.length} filtered out)`
);
return filteredMedia;
} catch (error) {
window.VSC.logger.error(`Light media scan failed: ${error.message}`);
return [];
}
}
/**
* Scan iframes for media elements
* @param {Document} document - Document to scan
* @returns {Array<HTMLMediaElement>} Found media elements in iframes
*/
scanIframes(document) {
const mediaElements = [];
const frameTags = document.getElementsByTagName('iframe');
Array.prototype.forEach.call(frameTags, (frame) => {
// Ignore frames we don't have permission to access (different origin)
try {
const childDocument = frame.contentDocument;
if (childDocument) {
const iframeMedia = this.scanForMedia(childDocument);
mediaElements.push(...iframeMedia);
window.VSC.logger.debug(`Found ${iframeMedia.length} media elements in iframe`);
}
} catch (e) {
window.VSC.logger.debug(`Cannot access iframe content (cross-origin): ${e.message}`);
}
});
return mediaElements;
}
/**
* Get media elements using site-specific container selectors
* @param {Document} document - Document to scan
* @returns {Array<HTMLMediaElement>} Found media elements
*/
scanSiteSpecificContainers(document) {
const mediaElements = [];
const containerSelectors = this.siteHandler.getVideoContainerSelectors();
const audioEnabled = this.config.settings.audioBoolean;
containerSelectors.forEach((selector) => {
try {
const containers = document.querySelectorAll(selector);
containers.forEach((container) => {
const containerMedia = window.VSC.DomUtils.findMediaElements(container, audioEnabled);
mediaElements.push(...containerMedia);
});
} catch (e) {
window.VSC.logger.warn(`Invalid selector "${selector}": ${e.message}`);
}
});
return mediaElements;
}
/**
* Comprehensive scan for all media elements
* @param {Document} document - Document to scan
* @returns {Array<HTMLMediaElement>} All found media elements
*/
scanAll(document) {
const allMedia = [];
// Regular scan
const regularMedia = this.scanForMedia(document);
allMedia.push(...regularMedia);
// Site-specific container scan
const containerMedia = this.scanSiteSpecificContainers(document);
allMedia.push(...containerMedia);
// Iframe scan
const iframeMedia = this.scanIframes(document);
allMedia.push(...iframeMedia);
// Remove duplicates
const uniqueMedia = [...new Set(allMedia)];
window.VSC.logger.info(`Total unique media elements found: ${uniqueMedia.length}`);
return uniqueMedia;
}
/**
* Check if media element is valid for controller attachment
* @param {HTMLMediaElement} media - Media element to check
* @returns {boolean} True if valid
*/
isValidMediaElement(media) {
// Skip videos that are not in the DOM
if (!media.isConnected) {
window.VSC.logger.debug('Video not in DOM');
return false;
}
// Skip audio elements when audio support is disabled
if (media.tagName === 'AUDIO' && !this.config.settings.audioBoolean) {
window.VSC.logger.debug('Audio element rejected - audioBoolean disabled');
return false;
}
// Let site handler have final say on whether to ignore this video
if (this.siteHandler.shouldIgnoreVideo(media)) {
window.VSC.logger.debug('Video ignored by site handler');
return false;
}
// Accept all connected media elements that pass site handler validation
// Visibility and size will be handled by controller initialization
return true;
}
/**
* Check if media element should start with hidden controller
* @param {HTMLMediaElement} media - Media element to check
* @returns {boolean} True if controller should start hidden
*/
shouldStartHidden(media) {
// For audio elements, only hide controller if audio support is disabled
// Audio players are often intentionally invisible but still functional
if (media.tagName === 'AUDIO') {
if (!this.config.settings.audioBoolean) {
window.VSC.logger.debug('Audio controller hidden - audio support disabled');
return true;
}
// Audio elements can be functional even when invisible
// Only hide if the audio element is explicitly disabled or has no functionality
if (media.disabled || media.style.pointerEvents === 'none') {
window.VSC.logger.debug('Audio controller hidden - element disabled or no pointer events');
return true;
}
// Keep audio controllers visible even for hidden audio elements
window.VSC.logger.debug(
'Audio controller will start visible (audio elements can be invisible but functional)'
);
return false;
}
// For video elements, check visibility - only hide controllers for truly invisible media elements
const style = window.getComputedStyle(media);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
window.VSC.logger.debug('Video not visible, controller will start hidden');
return true;
}
// All visible media elements get visible controllers regardless of size
return false;
}
/**
* Find the best parent element for controller positioning
* @param {HTMLMediaElement} media - Media element
* @returns {HTMLElement} Parent element for positioning
*/
findControllerParent(media) {
const positioning = this.siteHandler.getControllerPosition(media.parentElement, media);
return positioning.targetParent || media.parentElement;
}
}
// Create singleton instance
window.VSC.MediaElementObserver = MediaElementObserver;
================================================
FILE: src/observers/mutation-observer.js
================================================
/**
* DOM mutation observer for detecting video elements
*/
window.VSC = window.VSC || {};
class VideoMutationObserver {
constructor(config, onVideoFound, onVideoRemoved, mediaObserver) {
this.config = config;
this.onVideoFound = onVideoFound;
this.onVideoRemoved = onVideoRemoved;
this.mediaObserver = mediaObserver;
this.observer = null;
this.shadowObservers = new Set();
}
/**
* Start observing DOM mutations
* @param {Document} document - Document to observe
*/
start(document) {
this.observer = new MutationObserver((mutations) => {
// Process DOM nodes with reasonable delay
requestIdleCallback(
() => {
this.processMutations(mutations);
},
{ timeout: 2000 }
);
});
const observerOptions = {
attributeFilter: ['aria-hidden', 'data-focus-method', 'style', 'class'],
childList: true,
subtree: true,
};
this.observer.observe(document, observerOptions);
window.VSC.logger.debug('Video mutation observer started');
}
/**
* Process mutation events
* @param {Array<MutationRecord>} mutations - Mutation records
* @private
*/
processMutations(mutations) {
mutations.forEach((mutation) => {
switch (mutation.type) {
case 'childList':
this.processChildListMutation(mutation);
break;
case 'attributes':
this.processAttributeMutation(mutation);
break;
}
});
}
/**
* Process child list mutations (added/removed nodes)
* @param {MutationRecord} mutation - Mutation record
* @private
*/
processChildListMutation(mutation) {
// Handle added nodes
mutation.addedNodes.forEach((node) => {
// Only process element nodes (nodeType 1)
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
return;
}
if (node === document.documentElement) {
// Document was replaced (e.g., watch.sling.com uses document.write)
window.VSC.logger.debug('Document was replaced, reinitializing');
this.onDocumentReplaced();
return;
}
this.checkForVideoAndShadowRoot(node, node.parentNode || mutation.target, true);
});
// Handle removed nodes
mutation.removedNodes.forEach((node) => {
// Only process element nodes (nodeType 1)
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
return;
}
this.checkForVideoAndShadowRoot(node, node.parentNode || mutation.target, false);
});
}
/**
* Process attribute mutations
* @param {MutationRecord} mutation - Mutation record
* @private
*/
processAttributeMutation(mutation) {
// Handle style and class changes that might affect video visibility
if (mutation.attributeName === 'style' || mutation.attributeName === 'class') {
this.handleVisibilityChanges(mutation.target);
}
// Handle special cases like Apple TV+ player
if (
(mutation.target.attributes['aria-hidden'] &&
mutation.target.attributes['aria-hidden'].value === 'false') ||
mutation.target.nodeName === 'APPLE-TV-PLUS-PLAYER'
) {
const flattenedNodes = window.VSC.DomUtils.getShadow(document.body);
const videoNodes = flattenedNodes.filter((x) => x.tagName === 'VIDEO');
for (const node of videoNodes) {
// Only add vsc the first time for the apple-tv case
if (node.vsc && mutation.target.nodeName === 'APPLE-TV-PLUS-PLAYER') {
continue;
}
if (node.vsc) {
node.vsc.remove();
}
this.checkForVideoAndShadowRoot(node, node.parentNode || mutation.target, true);
}
}
}
/**
* Handle visibility changes on elements that might contain videos
* @param {Element} element - Element that had style/class changes
* @private
*/
handleVisibilityChanges(element) {
// If the element itself is a video
if (
element.tagName === 'VIDEO' ||
(element.tagName === 'AUDIO' && this.config.settings.audioBoolean)
) {
this.recheckVideoElement(element);
return;
}
// Check if element contains videos
const audioEnabled = this.config.settings.audioBoolean;
const mediaTagSelector = audioEnabled ? 'video,audio' : 'video';
const videos = element.querySelectorAll ? element.querySelectorAll(mediaTagSelector) : [];
videos.forEach((video) => {
this.recheckVideoElement(video);
});
}
/**
* Re-check if a video element should have a controller attached
* @param {HTMLMediaElement} video - Video element to recheck
* @private
*/
recheckVideoElement(video) {
if (!this.mediaObserver) {
return;
}
if (video.vsc) {
// Video already has controller, check if it should be removed or just hidden
if (!this.mediaObserver.isValidMediaElement(video)) {
window.VSC.logger.debug('Video became invalid, removing controller');
video.vsc.remove();
video.vsc = null;
} else {
// Video is still valid, update visibility based on current state
video.vsc.updateVisibility();
}
} else {
// Video doesn't have controller, check if it should get one
if (this.mediaObserver.isValidMediaElement(video)) {
window.VSC.logger.debug('Video became valid, attaching controller');
this.onVideoFound(video, video.parentElement || video.parentNode);
}
}
}
/**
* Check if node is or contains video elements
* @param {Node} node - Node to check
* @param {Node} parent - Parent node
* @param {boolean} added - True if node was added, false if removed
* @private
*/
checkForVideoAndShadowRoot(node, parent, added) {
// Only proceed with removal if node is missing from DOM
if (!added && document.body?.contains(node)) {
return;
}
if (
node.nodeName === 'VIDEO' ||
(node.nodeName === 'AUDIO' && this.config.settings.audioBoolean)
) {
if (added) {
this.onVideoFound(node, parent);
} else {
if (node.vsc) {
this.onVideoRemoved(node);
}
}
} else {
this.processNodeChildren(node, parent, added);
}
}
/**
* Process children of a node recursively
* @param {Node} node - Node to process
* @param {Node} parent - Parent node
* @param {boolean} added - True if node was added
* @private
*/
processNodeChildren(node, parent, added) {
let children = [];
// Handle shadow DOM
if (node.shadowRoot) {
this.observeShadowRoot(node.shadowRoot);
children = Array.from(node.shadowRoot.children);
}
// Handle regular children
if (node.children) {
children = [...children, ...Array.from(node.children)];
}
// Process all children
for (const child of children) {
this.checkForVideoAndShadowRoot(child, child.parentNode || parent, added);
}
}
/**
* Set up observer for shadow root
* @param {ShadowRoot} shadowRoot - Shadow root to observe
* @private
*/
observeShadowRoot(shadowRoot) {
if (this.shadowObservers.has(shadowRoot)) {
return; // Already observing
}
const shadowObserver = new MutationObserver((mutations) => {
requestIdleCallback(
() => {
this.processMutations(mutations);
},
{ timeout: 500 }
);
});
const observerOptions = {
attributeFilter: ['aria-hidden', 'data-focus-method'],
childList: true,
subtree: true,
};
shadowObserver.observe(shadowRoot, observerOptions);
this.shadowObservers.add(shadowRoot);
window.VSC.logger.debug('Shadow root observer added');
}
/**
* Handle document replacement
* @private
*/
onDocumentReplaced() {
// This callback should trigger reinitialization
window.VSC.logger.warn('Document replacement detected - full reinitialization needed');
}
/**
* Stop observing and clean up
*/
stop() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// Clean up shadow observers
this.shadowObservers.forEach((_shadowRoot) => {
// Note: We can't access the observer directly, but disconnecting the main
// observer should handle most cases. Shadow observers will be garbage collected.
});
this.shadowObservers.clear();
window.VSC.logger.debug('Video mutation observer stopped');
}
}
// Create singleton instance
window.VSC.VideoMutationObserver = VideoMutationObserver;
================================================
FILE: src/site-handlers/amazon-handler.js
================================================
/**
* Amazon Prime Video handler
*/
window.VSC = window.VSC || {};
class AmazonHandler extends window.VSC.BaseSiteHandler {
/**
* Check if this handler applies to Amazon
* @returns {boolean} True if on Amazon
*/
static matches() {
return (
location.hostname === 'www.amazon.com' ||
location.hostname === 'www.primevideo.com' ||
location.hostname.includes('amazon.') ||
location.hostname.includes('primevideo.')
);
}
/**
* Get Amazon-specific controller positioning
* @param {HTMLElement} parent - Parent element
* @param {HTMLElement} video - Video element
* @returns {Object} Positioning information
*/
getControllerPosition(parent, video) {
// Only special-case Prime Video, not product-page videos (which use "vjs-tech")
// Otherwise the overlay disappears in fullscreen mode
if (!video.classList.contains('vjs-tech')) {
return {
insertionPoint: parent.parentElement,
insertionMethod: 'beforeParent',
targetParent: parent.parentElement,
};
}
// Default positioning for product videos
return super.getControllerPosition(parent, video);
}
/**
* Check if video should be ignored on Amazon
* @param {HTMLMediaElement} video - Video element
* @returns {boolean} True if video should be ignored
*/
shouldIgnoreVideo(video) {
// Don't reject videos that are still loading
if (video.readyState < 2) {
return false;
}
// Ignore product preview videos that are too small
const rect = video.getBoundingClientRect();
return rect.width < 200 || rect.height < 100;
}
/**
* Get Amazon-specific video container selectors
* @returns {Array<string>} CSS selectors
*/
getVideoContainerSelectors() {
return ['.dv-player-container', '.webPlayerContainer', '[data-testid="video-player"]'];
}
}
// Create singleton instance
window.VSC.AmazonHandler = AmazonHandler;
================================================
FILE: src/site-handlers/apple-handler.js
================================================
/**
* Apple TV+ handler
*/
window.VSC = window.VSC || {};
class AppleHandler extends window.VSC.BaseSiteHandler {
/**
* Check if this handler applies to Apple TV+
* @returns {boolean} True if on Apple TV+
*/
static matches() {
return location.hostname === 'tv.apple.com';
}
/**
* Get Apple TV+-specific controller positioning
* @param {HTMLElement} parent - Parent element
* @param {HTMLElement} video - Video element
* @returns {Object} Positioning information
*/
getControllerPosition(parent, _video) {
// Insert before parent to bypass overlay
return {
insertionPoint: parent.parentNode,
insertionMethod: 'firstChild',
targetParent: parent.parentNode,
};
}
/**
* Get Apple TV+-specific video container selectors
* @returns {Array<string>} CSS selectors
*/
getVideoContainerSelectors() {
return ['apple-tv-plus-player', '[data-testid="player"]', '.video-container'];
}
/**
* Handle special video detection for Apple TV+
* @param {Document} document - Document object
* @returns {Array<HTMLMediaElement>} Additional videos found
*/
detectSpecialVideos(document) {
// Apple TV+ uses custom elements that may contain videos
const applePlayer = document.querySelector('apple-tv-plus-player');
if (applePlayer && applePlayer.shadowRoot) {
const videos = applePlayer.shadowRoot.querySelectorAll('video');
return Array.from(videos);
}
return [];
}
}
// Create singleton instance
window.VSC.AppleHandler = AppleHandler;
================================================
FILE: src/site-handlers/base-handler.js
================================================
/**
* Base class for site-specific handlers
*/
window.VSC = window.VSC || {};
class BaseSiteHandler {
constructor() {
this.hostname = location.hostname;
}
/**
* Check if this handler applies to the current site
* @returns {boolean} True if handler applies
*/
static matches() {
return false; // Override in subclasses
}
/**
* Get the site-specific positioning for the controller
* @param {HTMLElement} parent - Parent element
* @param {HTMLElement} video - Video element
* @returns {Object} Positioning information
*/
getControllerPosition(parent, _video) {
return {
insertionPoint: parent,
insertionMethod: 'firstChild', // 'firstChild', 'beforeParent', 'afterParent'
targetParent: parent,
};
}
/**
* Handle site-specific seeking functionality
* @param {HTMLMediaElement} video - Video element
* @param {number} seekSeconds - Seconds to seek
* @returns {boolean} True if handled, false for default behavior
*/
handleSeek(video, seekSeconds) {
// Default implementation - use standard seeking with bounds checking (original logic)
if (video.currentTime !== undefined && video.duration) {
const newTime = Math.max(0, Math.min(video.duration, video.currentTime + seekSeconds));
video.currentTime = newTime;
} else {
// Fallback for videos without duration
video.currentTime += seekSeconds;
}
return true;
}
/**
* Handle site-specific initialization
* @param {Document} document - Document object
*/
initialize(_document) {
window.VSC.logger.debug(`Initializing ${this.constructor.name} for ${this.hostname}`);
}
/**
* Handle site-specific cleanup
*/
cleanup() {
window.VSC.logger.debug(`Cleaning up ${this.constructor.name}`);
}
/**
* Check if video element should be ignored
* @param {HTMLMediaElement} video - Video element
* @returns {boolean} True if video should be ignored
*/
shouldIgnoreVideo(_video) {
return false;
}
/**
* Get site-specific CSS selectors for video containers
* @returns {Array<string>} CSS selectors
*/
getVideoContainerSelectors() {
return [];
}
/**
* Handle special video detection logic
* @param {Document} document - Document object
* @returns {Array<HTMLMediaElement>} Additional videos found
*/
detectSpecialVideos(_document) {
return [];
}
}
// Create singleton instance
window.VSC.BaseSiteHandler = BaseSiteHandler;
================================================
FILE: src/site-handlers/facebook-handler.js
================================================
/**
* Facebook-specific handler
*/
window.VSC = window.VSC || {};
class FacebookHandler extends window.VSC.BaseSiteHandler {
/**
* Check if this handler applies to Facebook
* @returns {boolean} True if on Facebook
*/
static matches() {
return location.hostname === 'www.facebook.com';
}
/**
* Get Facebook-specific controller positioning
* @param {HTMLElement} parent - Parent element
* @param {HTMLElement} video - Video element
* @returns {Object} Positioning information
*/
getControllerPosition(parent, _video) {
// Facebook requires deep DOM traversal due to complex nesting
// This is a monstrosity but new FB design does not have semantic handles
let targetParent = parent;
try {
targetParent =
parent.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement
.parentElement;
} catch (e) {
window.VSC.logger.warn('Facebook DOM structure changed, using fallback positioning');
targetParent = parent.parentElement;
}
return {
insertionPoint: targetParent,
insertionMethod: 'firstChild',
targetParent: targetParent,
};
}
/**
* Initialize Facebook-specific functionality
* @param {Document} document - Document object
*/
initialize(document) {
super.initialize(document);
// Facebook's dynamic content requires special handling
this.setupFacebookObserver(document);
}
/**
* Set up observer for Facebook's dynamic content loading
* @param {Document} document - Document object
* @private
*/
setupFacebookObserver(document) {
// Facebook loads content dynamically, so we need to watch for new videos
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const videos = node.querySelectorAll && node.querySelectorAll('video');
if (videos && videos.length > 0) {
window.VSC.logger.debug(`Facebook: Found ${videos.length} new videos`);
// Signal that new videos were found
this.onNewVideosDetected(Array.from(videos));
}
}
});
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
this.facebookObserver = observer;
window.VSC.logger.debug('Facebook dynamic content observer set up');
}
/**
* Handle new videos detected in Facebook's dynamic content
* @param {Array<HTMLMediaElement>} videos - New video elements
* @private
*/
onNewVideosDetected(videos) {
// This could be used to automatically attach controllers to new videos
// For now, just log the detection
window.VSC.logger.debug(`Facebook: ${videos.length} new videos detected`);
}
/**
* Check if video should be ignored on Facebook
* @param {HTMLMediaElement} video - Video element
* @returns {boolean} True if video should be ignored
*/
shouldIgnoreVideo(video) {
// Ignore story videos and other non-main content
return (
video.closest('[data-story-id]') !== null ||
video.closest('.story-bucket-container') !== null ||
video.getAttribute('data-video-width') === '0'
);
}
/**
* Get Facebook-specific video container selectors
* @returns {Array<string>} CSS selectors
*/
getVideoContainerSelectors() {
return ['[data-video-id]', '.video-container', '.fbStoryVideoContainer', '[role="main"] video'];
}
/**
* Cleanup Facebook-specific resources
*/
cleanup() {
super.cleanup();
if (this.facebookObserver) {
this.facebookObserver.disconnect();
this.facebookObserver = null;
}
}
}
// Create singleton instance
window.VSC.FacebookHandler = FacebookHandler;
================================================
FILE: src/site-handlers/index.js
================================================
/**
* Site handler factory and manager
*/
window.VSC = window.VSC || {};
class SiteHandlerManager {
constructor() {
this.currentHandler = null;
this.availableHandlers = [
window.VSC.NetflixHandler,
window.VSC.YouTubeHandler,
window.VSC.FacebookHandler,
window.VSC.AmazonHandler,
window.VSC.AppleHandler,
];
}
/**
* Get the appropriate handler for the current site
* @returns {BaseSiteHandler} Site handler instance
*/
getCurrentHandler() {
if (!this.currentHandler) {
this.currentHandler = this.detectHandler();
}
return this.currentHandler;
}
/**
* Detect which handler to use for the current site
* @returns {BaseSiteHandler} Site handler instance
* @private
*/
detectHandler() {
for (const HandlerClass of this.availableHandlers) {
if (HandlerClass.matches()) {
window.VSC.logger.info(`Using ${HandlerClass.name} for ${location.hostname}`);
return new HandlerClass();
}
}
window.VSC.logger.debug(`Using BaseSiteHandler for ${location.hostname}`);
return new window.VSC.BaseSiteHandler();
}
/**
* Initialize the current site handler
* @param {Document} document - Document object
*/
initialize(document) {
const handler = this.getCurrentHandler();
handler.initialize(document);
}
/**
* Get controller positioning for current site
* @param {HTMLElement} parent - Parent element
* @param {HTMLElement} video - Video element
* @returns {Object} Positioning information
*/
getControllerPosition(parent, video) {
const handler = this.getCurrentHandler();
return handler.getControllerPosition(parent, video);
}
/**
* Handle seeking for current site
* @param {HTMLMediaElement} video - Video element
* @param {number} seekSeconds - Seconds to seek
* @returns {boolean} True if handled
*/
handleSeek(video, seekSeconds) {
const handler = this.getCurrentHandler();
return handler.handleSeek(video, seekSeconds);
}
/**
* Check if a video should be ignored
* @param {HTMLMediaElement} video - Video element
* @returns {boolean} True if video should be ignored
*/
shouldIgnoreVideo(video) {
const handler = this.getCurrentHandler();
return handler.shouldIgnoreVideo(video);
}
/**
* Get video container selectors for current site
* @returns {Array<string>} CSS selectors
*/
getVideoContainerSelectors() {
const handler = this.getCurrentHandler();
return handler.getVideoContainerSelectors();
}
/**
* Detect special videos for current site
* @param {Document} document - Document object
* @returns {Array<HTMLMediaElement>} Additional videos found
*/
detectSpecialVideos(document) {
const handler = this.getCurrentHandler();
return handler.detectSpecialVideos(document);
}
/**
* Cleanup current handler
*/
cleanup() {
if (this.currentHandler) {
this.currentHandler.cleanup();
this.currentHandler = null;
}
}
/**
* Force refresh of current handler (useful for SPA navigation)
*/
refresh() {
this.cleanup();
this.currentHandler = null;
}
}
// Create singleton instance
window.VSC.siteHandlerManager = new SiteHandlerManager();
================================================
FILE: src/site-handlers/netflix-handler.js
================================================
/**
* Netflix-specific handler
*/
window.VSC = window.VSC || {};
class NetflixHandler extends window.VSC.BaseSiteHandler {
/**
* Check if this handler applies to Netflix
* @returns {boolean} True if on Netflix
*/
static matches() {
return location.hostname === 'www.netflix.com';
}
/**
* Get Netflix-specific controller positioning
* @param {HTMLElement} parent - Parent element
* @param {HTMLElement} video - Video element
* @returns {Object} Positioning information
*/
getControllerPosition(parent, _video) {
// Insert before parent to bypass Netflix's overlay
return {
insertionPoint: parent.parentElement,
insertionMethod: 'beforeParent',
targetParent: parent.parentElement,
};
}
/**
* Handle Netflix-specific seeking using their API
* @param {HTMLMediaElement} video - Video element
* @param {number} seekSeconds - Seconds to seek
* @returns {boolean} True if handled
*/
handleSeek(video, seekSeconds) {
try {
// Use Netflix's postMessage API for seeking
window.postMessage(
{
action: 'videospeed-seek',
seekMs: seekSeconds * 1000,
},
'https://www.netflix.com'
);
window.VSC.logger.debug(`Netflix seek: ${seekSeconds} seconds`);
return true;
} catch (error) {
window.VSC.logger.error(`Netflix seek failed: ${error.message}`);
// Fallback to default seeking
video.currentTime += seekSeconds;
return true;
}
}
/**
* Initialize Netflix-specific functionality
* @param {Document} document - Document object
*/
initialize(document) {
super.initialize(document);
// Netflix-specific script injection is handled by content script (injector.js)
// since Chrome APIs are not available in injected page context
window.VSC.logger.debug(
'Netflix handler initialized - script injection handled by content script'
);
}
/**
* Check if video should be ignored on Netflix
* @param {HTMLMediaElement} video - Video element
* @returns {boolean} True if video should be ignored
*/
shouldIgnoreVideo(video) {
// Ignore preview videos or thumbnails
return (
video.classList.contains('preview-video') ||
video.parentElement?.classList.contains('billboard-row')
);
}
/**
* Get Netflix-specific video container selectors
* @returns {Array<string>} CSS selectors
*/
getVideoContainerSelectors() {
return ['.watch-video', '.nfp-container', '#netflix-player'];
}
}
// Create singleton instance
window.VSC.NetflixHandler = NetflixHandler;
================================================
FILE: src/site-handlers/scripts/netflix.js
================================================
window.addEventListener('message', function(event) {
if (event.origin != 'https://www.netflix.com' || event.data.action != 'videospeed-seek' || !event.data.seekMs) { return; };
const videoPlayer = window.netflix.appContext.state.playerApp.getAPI().videoPlayer;
const playerSessionId = videoPlayer.getAllPlayerSessionIds()[0];
const currentTime = videoPlayer.getCurrentTimeBySessionId(playerSessionId);
videoPlayer.getVideoPlayerBySessionId(playerSessionId).seek(currentTime + event.data.seekMs);
}, false);
================================================
FILE: src/site-handlers/youtube-handler.js
================================================
/**
* YouTube-specific handler
*/
window.VSC = window.VSC || {};
class YouTubeHandler extends window.VSC.BaseSiteHandler {
/**
* Check if this handler applies to YouTube
* @returns {boolean} True if on YouTube
*/
static matches() {
return location.hostname === 'www.youtube.com';
}
/**
* Get YouTube-specific controller positioning
* @param {HTMLElement} parent - Parent element
* @param {HTMLElement} video - Video element
* @returns {Object} Positioning information
*/
getControllerPosition(parent, _video) {
// YouTube requires special positioning to ensure controller is on top
const targetParent = parent.parentElement;
return {
insertionPoint: targetParent,
insertionMethod: 'firstChild',
targetParent: targetParent,
};
}
/**
* Initialize YouTube-specific functionality
* @param {Document} document - Document object
*/
initialize(document) {
super.initialize(document);
// Set up YouTube-specific CSS handling
this.setupYouTubeCSS();
}
/**
* Set up YouTube-specific CSS classes and positioning
* @private
*/
setupYouTubeCSS() {
// YouTube has complex CSS that can hide our controller
// The inject.css already handles this, but we could add dynamic adjustments here
window.VSC.logger.debug('YouTube CSS setup completed');
}
/**
* Check if video should be ignored on YouTube
* @param {HTMLMediaElement} video - Video element
* @returns {boolean} True if video should be ignored
*/
shouldIgnoreVideo(video) {
// Ignore thumbnail videos and ads
return (
video.classList.contains('video-thumbnail') ||
video.parentElement?.classList.contains('ytp-ad-player-overlay')
);
}
/**
* Get YouTube-specific video container selectors
* @returns {Array<string>} CSS selectors
*/
getVideoContainerSelectors() {
return ['.html5-video-player', '#movie_player', '.ytp-player-content'];
}
/**
* Handle special video detection for YouTube
* @param {Document} document - Document object
* @returns {Array<HTMLMediaElement>} Additional videos found
*/
detectSpecialVideos(document) {
const videos = [];
// Look for videos in iframes (embedded players)
try {
const iframes = document.querySelectorAll('iframe[src*="youtube.com"]');
iframes.forEach((iframe) => {
try {
const iframeDoc = iframe.contentDocument;
if (iframeDoc) {
const iframeVideos = iframeDoc.querySelectorAll('video');
videos.push(...Array.from(iframeVideos));
}
} catch (e) {
// Cross-origin iframe, ignore
}
});
} catch (e) {
window.VSC.logger.debug(`Could not access YouTube iframe videos: ${e.message}`);
}
return videos;
}
/**
* Handle YouTube-specific player state changes
* @param {HTMLMediaElement} video - Video element
*/
onPlayerStateChange(_video) {
// YouTube fires custom events we could listen to
// This could be used for better integration with YouTube's player
window.VSC.logger.debug('YouTube player state changed');
}
}
// Create singleton instance
window.VSC.YouTubeHandler = YouTubeHandler;
================================================
FILE: src/styles/inject.css
================================================
vsc-controller {
/* Default visible state */
visibility: visible;
opacity: 1;
display: block;
width: auto !important;
height: auto !important;
/* In case of pages using `white-space: pre-line` (eg Discord), don't render vsc's whitespace */
white-space: normal;
/* Disables text selection when the user is dragging the controller around */
user-select: none;
}
/* Origin specific overrides */
/* YouTube player */
.ytp-hide-info-bar vsc-controller {
position: relative;
top: 10px;
}
.ytp-autohide vsc-controller {
visibility: hidden;
transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
}
.ytp-autohide .vsc-show {
visibility: visible;
opacity: 1;
}
/* 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,
.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;
}
/* disable Vimeo video overlay */
div.video-wrapper+div.target {
height: 0;
}
/* Fix black overlay on Kickstarter */
div.video-player.has_played.vertically_center:before,
div.legacy-video-player.has_played.vertically_center:before {
content: none !important;
}
/* Fix black overlay on openai.com */
.Shared-Video-player>vsc-controller {
height: 0;
}
/* Fix black overlay on Amazon Prime Video */
.dv-player-fullscreen vsc-controller {
height: 0;
}
/* Fix for Google Drive overlay: https://github.com/igrigorik/videospeed/issues/1156 */
#player .html5-video-container vsc-controller {
position: relative;
top: 10px;
}
section[role="tabpanel"][aria-label="Video Player"] {
top: 80px;
}
/* ChatGPT audio controller positioning */
/* https://chatgpt.com */
:root[style*='--vsc-domain: "chatgpt.com"'] vsc-controller {
position: relative !important;
top: 0px !important;
left: 35px !important;
}
================================================
FILE: src/ui/controls.js
================================================
/**
* Control button interactions and event handling
*/
window.VSC = window.VSC || {};
class ControlsManager {
constructor(actionHandler, config) {
this.actionHandler = actionHandler;
this.config = config;
}
/**
* Set up control button event listeners
* @param {ShadowRoot} shadow - Shadow root containing controls
* @param {HTMLVideoElement} video - Associated video element
*/
setupControlEvents(shadow, video) {
this.setupDragHandler(shadow);
this.setupButtonHandlers(shadow);
this.setupWheelHandler(shadow, video);
this.setupClickPrevention(shadow);
}
/**
* Set up drag handler for speed indicator
* @param {ShadowRoot} shadow - Shadow root
* @private
*/
setupDragHandler(shadow) {
const draggable = shadow.querySelector('.draggable');
draggable.addEventListener(
'mousedown',
(e) => {
this.actionHandler.runAction(e.target.dataset['action'], false, e);
e.stopPropagation();
e.preventDefault();
},
true
);
}
/**
* Set up button click handlers
* @param {ShadowRoot} shadow - Shadow root
* @private
*/
setupButtonHandlers(shadow) {
shadow.querySelectorAll('button').forEach((button) => {
// Click handler
button.addEventListener(
'click',
(e) => {
this.actionHandler.runAction(
e.target.dataset['action'],
this.config.getKeyBinding(e.target.dataset['action']),
e
);
e.stopPropagation();
},
true
);
// Touch handler to prevent conflicts
button.addEventListener(
'touchstart',
(e) => {
e.stopPropagation();
},
true
);
});
}
/**
* Set up mouse wheel handler for speed control with touchpad filtering
*
* Cross-browser wheel event behavior:
* - Chrome/Safari/Edge: ALL devices use DOM_DELTA_PIXEL (mouse wheels ~100px, touchpads ~1-15px)
* - Firefox: Mouse wheels use DOM_DELTA_LINE, touchpads use DOM_DELTA_PIXEL
*
* Detection strategy: Use magnitude threshold in DOM_DELTA_PIXEL mode to distinguish
* mouse wheels (±100px typical) from touchpads (±1-15px typical). Threshold of 50px
* provides safety margin based on empirical browser testing.
*
* @param {ShadowRoot} shadow - Shadow root
* @param {HTMLVideoElement} video - Video element
* @private
*/
setupWheelHandler(shadow, video) {
const controller = shadow.querySelector('#controller');
controller.addEventListener(
'wheel',
(event) => {
// Detect and filter touchpad events to prevent interference during page scrolling
if (event.deltaMode === event.DOM_DELTA_PIXEL) {
// Chrome/Safari/Edge: Use magnitude to distinguish mouse wheel (>50px) from touchpad (<50px)
const TOUCHPAD_THRESHOLD = 50;
if (Math.abs(event.deltaY) < TOUCHPAD_THRESHOLD) {
window.VSC.logger.debug(`Touchpad scroll detected (deltaY: ${event.deltaY}) - ignoring`);
return;
}
}
// Firefox: DOM_DELTA_LINE events are typically legitimate mouse wheels, allow them
event.preventDefault();
const delta = Math.sign(event.deltaY);
const step = 0.1;
const speedDelta = delta < 0 ? step : -step;
this.actionHandler.adjustSpeed(video, speedDelta, { relative: true });
window.VSC.logger.debug(`Wheel control: adjusting speed by ${speedDelta} (deltaMode: ${event.deltaMode}, deltaY: ${event.deltaY})`);
},
{ passive: false }
);
}
/**
* Set up click prevention for controller container
* @param {ShadowRoot} shadow - Shadow root
* @private
*/
setupClickPrevention(shadow) {
const controller = shadow.querySelector('#controller');
// Prevent clicks from bubbling up to page
controller.addEventListener('click', (e) => e.stopPropagation(), false);
controller.addEventListener('mousedown', (e) => e.stopPropagation(), false);
}
}
// Create singleton instance
window.VSC.ControlsManager = ControlsManager;
================================================
FILE: src/ui/drag-handler.js
================================================
/**
* Drag functionality for video controller
*/
window.VSC = window.VSC || {};
class DragHandler {
/**
* Handle dragging of video controller
* @param {HTMLVideoElement} video - Video element
* @param {MouseEvent} e - Mouse event
*/
static handleDrag(video, e) {
const controller = video.vsc.div;
const shadowController = controller.shadowRoot.querySelector('#controller');
// Find nearest parent of same size as video parent
const parentElement = window.VSC.DomUtils.findVideoParent(controller);
video.classList.add('vcs-dragging');
shadowController.classList.add('dragging');
const initialMouseXY = [e.clientX, e.clientY];
const initialControllerXY = [
parseInt(shadowController.style.left) || 0,
parseInt(shadowController.style.top) || 0,
];
const startDragging = (e) => {
const style = shadowController.style;
const dx = e.clientX - initialMouseXY[0];
const dy = e.clientY - initialMouseXY[1];
style.left = `${initialControllerXY[0] + dx}px`;
style.top = `${initialControllerXY[1] + dy}px`;
};
const stopDragging = () => {
parentElement.removeEventListener('mousemove', startDragging);
parentElement.removeEventListener('mouseup', stopDragging);
parentElement.removeEventListener('mouseleave', stopDragging);
shadowController.classList.remove('dragging');
video.classList.remove('vcs-dragging');
window.VSC.logger.debug('Drag operation completed');
};
parentElement.addEventListener('mouseup', stopDragging);
parentElement.addEventListener('mouseleave', stopDragging);
parentElement.addEventListener('mousemove', startDragging);
window.VSC.logger.debug('Drag operation started');
}
}
// Create singleton instance
window.VSC.DragHandler = DragHandler;
================================================
FILE: src/ui/options/options.css
================================================
/* Material Design Variables - matching popup.css */
:root {
/* Light theme */
--md-surface: #ffffff;
--md-surface-variant: #f3f3f3;
--md-surface-container: #fafafa;
--md-on-surface: #1c1b1f;
--md-on-surface-variant: #49454f;
--md-primary: #6750a4;
--md-primary-container: #eaddff;
--md-on-primary: #ffffff;
--md-on-primary-container: #21005d;
--md-outline: #79747e;
--md-outline-variant: #cac4d0;
--md-shadow: rgba(0, 0, 0, 0.1);
--md-shadow-2: rgba(0, 0, 0, 0.15);
/* Legacy variable mapping */
--main-bg-color: var(--md-surface);
--main-color: var(--md-on-surface);
--bg-shade: var(--md-surface-variant);
--color-shade: var(--md-on-surface-variant);
}
@media (prefers-color-scheme: dark) {
:root {
/* Dark theme */
--md-surface: #131316;
--md-surface-variant: #2b2930;
--md-surface-container: #1f1f23;
--md-on-surface: #e6e0e9;
--md-on-surface-variant: #cac4cf;
--md-primary: #d0bcff;
--md-primary-container: #4f378b;
--md-on-primary: #371e73;
--md-on-primary-container: #eaddff;
--md-outline: #938f99;
--md-outline-variant: #49454f;
--md-shadow: rgba(0, 0, 0, 0.3);
--md-shadow-2: rgba(0, 0, 0, 0.4);
}
a {
color: var(--md-primary);
}
a:visited {
color: var(--md-primary-container);
}
}
* {
box-sizing: border-box;
}
html {
max-width: 720px;
padding: 0 1em;
margin: auto;
line-height: 1.75;
font-size: 1em;
background-color: var(--md-surface);
color: var(--md-on-surface);
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
padding: 0 20px 40px;
}
header {
margin: 0 -20px 24px;
padding: 20px 40px;
background: var(--md-surface-container);
border-bottom: 1px solid var(--md-outline-variant);
}
h1 {
font-size: 28px;
font-weight: 400;
line-height: 1.2;
margin: 0;
color: var(--md-on-surface);
}
h3 {
font-size: 18px;
font-weight: 500;
margin: 32px 0 16px;
color: var(--md-on-surface);
}
section {
margin: 0 0 32px;
}
/* Material Design form elements */
.row {
margin: 16px 0;
display: flex;
align-items: flex-start;
gap: 16px;
}
/* First row after heading should have less top margin */
h3+.row {
margin-top: 8px;
}
.row.customs {
gap: 8px;
align-items: center;
display: grid;
grid-template-columns: 180px 75px 75px;
position: relative;
padding-left: 44px;
}
/* When advanced features are shown */
.row.customs:has(.customForce) {
grid-template-columns: 180px 75px 75px 200px;
}
/* Special handling for rows without value inputs */
.row.customs:has(input[style*="display: none"]) {
grid-template-columns: 180px 75px 0;
}
.row.customs:has(input[style*="display: none"]):has(.customForce) {
grid-template-columns: 180px 75px 0 200px;
}
.row.customs:has(input[style*="display: none"]) .customValue {
width: 0;
padding: 0;
border: none;
margin: 0;
}
/* Remove button styling - positioned absolutely on the left */
.removeParent {
position: absolute;
left: 4px;
top: 50%;
transform: translateY(-50%);
width: 32px;
min-width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
font-size: 18px;
font-weight: 600;
border-radius: 16px;
background-color: var(--md-surface-variant);
color: var(--md-on-surface);
border: 1px solid var(--md-outline-variant);
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* Hide remove button for predefined shortcuts */
.row.customs[id] .removeParent {
display: none;
}
/* Style disabled selects consistently */
select:disabled {
opacity: 0.8;
background-color: var(--md-surface-variant);
cursor: default;
}
select:disabled:hover {
border-color: var(--md-outline);
background-color: var(--md-surface-variant);
}
.removeParent:hover {
background-color: #ffebee;
color: #d32f2f;
border-color: #d32f2f;
transform: translateY(-50%) scale(1.05);
box-shadow: 0 2px 4px var(--md-shadow);
}
@media (prefers-color-scheme: dark) {
.removeParent:hover {
background-color: rgba(211, 47, 47, 0.15);
color: #ef5350;
border-color: #ef5350;
}
}
.removeParent:active {
transform: translateY(-50%) scale(0.95);
box-shadow: 0 1px 2px var(--md-shadow);
}
.removeParent:focus-visible {
outline: 2px solid var(--md-primary);
outline-offset: 2px;
}
/* Improved label styling with context */
label {
flex: 0 0 280px;
font-size: 14px;
line-height: 1.5;
color: var(--md-on-surface);
font-weight: 500;
}
label em {
display: block;
font-size: 12px;
color: var(--md-on-surface-variant);
font-weight: 400;
margin-top: 2px;
font-style: normal;
}
/* Material Design text inputs */
input[type="text"],
input[type="number"] {
padding: 8px 12px;
border: 1px solid var(--md-outline);
border-radius: 4px;
background-color: var(--md-surface);
color: var(--md-on-surface);
font-size: 14px;
transition: all 0.2s ease;
min-height: 40px;
width: 75px;
text-align: center;
}
/* Custom key input styling */
.customKey {
color: transparent;
text-shadow: 0 0 0 var(--md-on-surface);
}
/* Specific input widths */
#controllerOpacity,
#controllerButtonSize {
width: 60px;
}
input[type="text"]:hover,
input[type="number"]:hover {
border-color: var(--md-on-surface);
}
input[type="text"]:focus,
input[type="number"]:focus {
outline: none;
border-color: var(--md-primary);
box-shadow: 0 0 0 1px var(--md-primary);
}
input::placeholder {
opacity: 0.7;
color: var(--md-on-surface-variant);
}
input:disabled {
cursor: not-allowed;
opacity: 0.7;
background-color: var(--md-surface-variant);
}
input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--md-primary);
}
/* Material Design textarea */
textarea {
width: 100%;
padding: 12px;
border: 1px solid var(--md-outline);
border-radius: 4px;
background-color: var(--md-surface);
color: var(--md-on-surface);
font-size: 14px;
font-family: inherit;
resize: vertical;
transition: all 0.2s ease;
}
textarea:hover {
border-color: var(--md-on-surface);
}
textarea:focus {
outline: none;
border-color: var(--md-primary);
box-shadow: 0 0 0 1px var(--md-primary);
}
/* Customizable select styling */
select {
appearance: none;
padding: 8px 32px 8px 12px;
border: 1px solid var(--md-outline);
border-radius: 4px;
background-color: var(--md-surface);
color: var(--md-on-surface);
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
min-height: 40px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%2349454f' d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 6px center;
background-size: 18px;
}
@media (prefers-color-scheme: dark) {
select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23cac4cf' d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
}
}
select:hover {
border-color: var(--md-on-surface);
background-color: var(--md-surface-variant);
}
select:focus {
outline: none;
border-color: var(--md-primary);
box-shadow: 0 0 0 1px var(--md-primary);
}
/* Specific select widths */
.customDo {
width: 180px;
}
.customForce {
visibility: hidden;
opacity: 0;
width: 200px;
transition: all 0.3s ease;
}
/* Advanced features styling */
.customForce.show {
visibility: visible;
opacity: 1;
background-color: rgba(255, 235, 59, 0.1);
border-color: rgba(255, 193, 7, 0.8);
box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.2);
font-size: 12px;
}
@media (prefers-color-scheme: dark) {
.customForce.show {
background-color: rgba(255, 193, 7, 0.08);
border-color: rgba(255, 193, 7, 0.6);
box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.15);
}
}
#logLevel {
width: 120px;
}
/* Material Design buttons */
button {
appearance: none;
padding: 0 24px;
min-height: 40px;
border: none;
border-radius: 20px;
background-color: var(--md-primary);
color: var(--md-on-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
position: relative;
overflow: hidden;
}
button:hover {
box-shadow: 0 2px 4px var(--md-shadow);
transform: translateY(-1px);
}
button:active {
transform: translateY(0) scale(0.98);
box-shadow: 0 1px 2px var(--md-shadow);
}
button:focus-visible {
outline: 2px solid var(--md-primary);
outline-offset: 2px;
}
/* Secondary buttons */
.secondary {
background-color: transparent;
color: var(--md-primary);
border: 1px solid var(--md-outline);
}
.secondary:hover {
background-color: var(--md-primary-container);
color: var(--md-on-primary-container);
border-color: var(--md-primary);
}
/* Specific button styling */
#save {
background-color: var(--md-primary);
}
#experimental {
background-color: var(--md-surface-variant);
color: var(--md-on-surface);
}
#experimental:hover {
background-color: var(--md-outline-variant);
}
#restore {
background-color: transparent;
color: var(--md-on-surface-variant);
border: 1px solid var(--md-outline-variant);
}
#restore:hover {
background-color: rgba(234, 67, 53, 0.08);
color: #d32f2f;
border-color: #d32f2f;
}
@media (prefers-color-scheme: dark) {
#restore:hover {
background-color: rgba(244, 67, 54, 0.12);
color: #ef5350;
border-color: #ef5350;
}
}
#experimental:disabled {
background-color: rgba(255, 193, 7, 0.15);
color: var(--md-on-surface);
opacity: 1;
cursor: default;
}
#experimental:disabled:hover {
background-color: rgba(255, 193, 7, 0.15);
transform: none;
}
#add {
margin-top: 16px;
margin-left: 44px;
padding: 0 16px;
min-height: 36px;
font-size: 14px;
}
/* Button group layout */
.button-group {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 24px;
gap: 16px;
}
.primary-buttons {
display: flex;
gap: 12px;
}
/* Status message */
#status {
display: block;
margin-top: 16px;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
background-color: var(--md-primary-container);
color: var(--md-on-primary-container);
opacity: 0;
transition: all 0.3s ease;
text-align: center;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
#status.show {
opacity: 1;
transform: translateY(-4px);
}
/* Success state */
#status.success {
background-color: #4caf50;
color: white;
}
/* Error state */
#status.error {
background-color: #f44336;
color: white;
}
@media (prefers-color-scheme: dark) {
#status {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
#status.success {
background-color: #2e7d32;
}
#status.error {
background-color: #d32f2f;
}
}
/* Special row for blacklist textarea */
.row:has(#blacklist) {
flex-direction: column;
gap: 8px;
}
.row:has(#blacklist) label {
flex: none;
}
/* FAQ section */
#faq {
margin-top: 24px;
padding: 24px;
background-color: var(--md-surface-variant);
border-radius: 12px;
}
#faq h4 {
margin-top: 0;
margin-bottom: 12px;
font-size: 16px;
font-weight: 500;
color: var(--md-on-surface);
}
#faq p {
margin: 8px 0;
font-size: 14px;
color: var(--md-on-surface-variant);
}
#faq code {
padding: 2px 6px;
background-color: var(--md-surface);
border-radius: 4px;
font-size: 13px;
color: var(--md-primary);
}
/* Advanced features - hidden by default */
.row.advanced-feature {
visibility: hidden;
opacity: 0;
margin: 0;
padding: 0;
height: 0;
overflow: hidden;
transition: all 0.3s ease;
}
.row.advanced-feature.show {
visibility: visible;
opacity: 1;
height: auto;
display: flex;
margin: 16px 0;
align-items: flex-start;
gap: 16px;
}
/* Advanced feature inputs and selects */
.row.advanced-feature.show input[type="text"],
.row.advanced-feature.show select,
.row.advanced-feature.show textarea {
background-color: rgba(255, 235, 59, 0.1);
border-color: rgba(255, 193, 7, 0.8);
box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.2);
}
.row.advanced-feature.show input[type="text"]:hover,
.row.advanced-feature.show select:hover,
.row.advanced-feature.show textarea:hover {
border-color: rgba(255, 193, 7, 1);
box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.3);
}
.row.advanced-feature.show input[type="text"]:focus,
.row.advanced-feature.show select:focus,
.row.advanced-feature.show textarea:focus {
border-color: rgba(255, 152, 0, 1);
box-shadow: 0 0 0 3px rgba(255, 193, 7, 0.3);
}
@media (prefers-color-scheme: dark) {
.row.advanced-feature.show input[type="text"],
.row.advanced-feature.show select,
.row.advanced-feature.show textarea {
background-color: rgba(255, 235, 59, 0.08);
border-color: rgba(255, 193, 7, 0.6);
box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.15);
}
}
/* Responsive adjustments */
@media (max-width: 640px) {
.row {
flex-direction: column;
gap: 8px;
}
.row.customs {
display: flex;
flex-wrap: wrap;
padding-left: 44px;
}
.row.customs>* {
margin-bottom: 8px;
}
label {
flex: none;
}
select,
input[type="text"] {
width: 100%;
}
.customDo {
width: 100%;
}
.customForce {
width: 100%;
}
.button-group {
flex-direction: column;
align-items: stretch;
}
.primary-buttons {
width: 100%;
}
.primary-buttons button {
flex: 1;
}
}
/* Ripple effect for buttons */
button::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background-color: currentColor;
opacity: 0.1;
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
button:active::before {
width: 300px;
height: 300px;
}
================================================
FILE: src/ui/options/options.html
================================================
<!DOCTYPE html>
<html>
<head>
<title>Video Speed Controller: Options</title>
<link rel="stylesheet" href="options.css" />
<script src="options.js"></script>
</head>
<body>
<header>
<h1>Video Speed Controller</h1>
</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)" style="display: none;" />
</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)" />
</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)" />
</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)" />
</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)" />
</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)" />
</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)" />
</div>
<div class="row customs" id="mark">
<select class="customDo">
<option value="mark">Set marker</option>
</select>
<input class="customKey" type="text" value="" placeholder="press a key" />
<input class="customValue" type="text" placeholder="value (0)" style="display: none;" />
</div>
<div class="row customs" id="jump">
<select class="customDo">
<option value="jump">Jump to marker</option>
</select>
<input class="customKey" type="text" value="" placeholder="press a key" />
<input class="customValue" type="text" placeholder="value (0)" style="display: none;" />
</div>
<button id="add">Add New</button>
</section>
<section>
<h3>Other</h3>
<div class="row">
<label for="audioBoolean">Audio support</label>
<input id="audioBoolean" type="checkbox" />
</div>
<div class="row">
<label for="rememberSpeed">Remember playback speed</label>
<input id="rememberSpeed" 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="forceLastSavedSpeed">Force last saved speed<br />
<em>Prevent video players that override VSC speed</em></label>
<input id="forceLastSavedSpeed" type="checkbox" />
</div>
<div class="row advanced-feature">
<label for="controllerOpacity">Controller opacity</label>
<input id="controllerOpacity" type="text" value="" />
</div>
<div class="row advanced-feature">
<label for="controllerButtonSize">Controller Button Size</label>
<input id="controllerButtonSize" type="text" value="" />
</div>
<div class="row advanced-feature">
<label for="logLevel">Console Log Level<br />
<em>Set verbosity in the browser console</em></label>
<select id="logLevel">
<option value="1">None</option>
<option value="2">Error</option>
<option value="3">Warning</option>
<option value="4">Info</option>
<option value="5">Debug</option>
<option value="6">Verbose</option>
</select>
</div>
<div class="row advanced-feature">
<label for="blacklist">Disable on sites<br />
<em>One host or <a href="https://www.regexpal.com/">Regex</a> per line. Use the literal notation, e.g.
<code>/(.+)youtube\.com(\/*)$/gi</code></em>
</label>
<textarea id="blacklist" rows="10" cols="50"></textarea>
</label>
</section>
<div class="button-group">
<div class="primary-buttons">
<button id="save">Save</button>
<button id="experimental">Show advanced features</button>
</div>
<button id="restore">Restore defaults</button>
</div>
<div id="status"></div>
<section>
<h3>Help & Support</h3>
<div class="row">
<button id="about" class="secondary">About Video Speed Controller</button>
<button id="feedback" class="secondary">Send Feedback</button>
</div>
<div id="faq">
<h4>The speed controls are not showing up for local videos or Incognito mode?</h4>
<p>
To enable playback of local media (e.g. File > Open File) or Incognito mode, you need
to manually grant additional permissions to the extension.
</p>
<ul>
<li>In a new tab, navigate to <code>chrome://extensions</code></li>
<li>
Find "Video Speed Controller" extension in the list and enable "Allow
access to file URLs" and/or "Allow in Incognito".
</li>
</ul>
</div>
</section>
</body>
</html>
================================================
FILE: src/ui/options/options.js
================================================
/**
* Options page - depends on core VSC modules
* Import required dependencies that are normally bundled in inject context
*/
// Core utilities and constants - must load first
import '../../utils/constants.js';
import '../../utils/logger.js';
// Storage and settings - depends on utils
import '../../core/storage-manager.js';
import '../../core/settings.js';
// Initialize global namespace for options page
window.VSC = window.VSC || {};
// Debounce utility function
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
var keyBindings = [];
// Minimal blacklist - only keys that would interfere with form navigation
const BLACKLISTED_KEYCODES = [
9, // Tab - needed for keyboard navigation
16, // Shift (alone)
17, // Ctrl/Control (alone)
18, // Alt (alone)
91, // Meta/Windows/Command Left
92, // Meta/Windows Right
93, // Context Menu/Right Command
224 // Meta/Command (Firefox)
];
var keyCodeAliases = {
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",
124: "F13",
125: "F14",
126: "F15",
127: "F16",
128: "F17",
129: "F18",
130: "F19",
131: "F20",
132: "F21",
133: "F22",
134: "F23",
135: "F24",
186: ";",
188: "<",
189: "-",
187: "+",
190: ">",
191: "/",
192: "~",
219: "[",
220: "\\",
221: "]",
222: "'"
};
function recordKeyPress(e) {
// Special handling for backspace and escape
if (e.keyCode === 8) {
// Clear input when backspace pressed
e.target.value = "";
e.preventDefault();
e.stopPropagation();
return;
} else if (e.keyCode === 27) {
// When esc clicked, clear input
e.target.value = "null";
e.target.keyCode = null;
e.preventDefault();
e.stopPropagation();
return;
}
// Block blacklisted keys
if (BLACKLISTED_KEYCODES.includes(e.keyCode)) {
e.preventDefault();
e.stopPropagation();
return;
}
// Accept all other keys
// Use friendly name if available, otherwise show "Key {code}"
e.target.value = keyCodeAliases[e.keyCode] ||
(e.keyCode >= 48 && e.keyCode <= 90 ? String.fromCharCode(e.keyCode) : `Key ${e.keyCode}`);
e.target.keyCode = e.keyCode;
e.preventDefault();
e.stopPropagation();
}
function inputFilterNumbersOnly(e) {
var char = String.fromCharCode(e.keyCode);
if (!/[\d\.]$/.test(char) || !/^\d+(\.\d*)?$/.test(e.target.value + char)) {
e.preventDefault();
e.stopPropagation();
}
}
function inputFocus(e) {
e.target.value = "";
}
function inputBlur(e) {
const keyCode = e.target.keyCode;
e.target.value = keyCodeAliases[keyCode] ||
(keyCode >= 48 && keyCode <= 90 ? String.fromCharCode(keyCode) : `Key ${keyCode}`);
}
function updateShortcutInputText(inputId, keyCode) {
const input = document.getElementById(inputId);
input.value = keyCodeAliases[keyCode] ||
(keyCode >= 48 && keyCode <= 90 ? String.fromCharCode(keyCode) : `Key ${keyCode}`);
input.keyCode = keyCode;
}
function updateCustomShortcutInputText(inputItem, keyCode) {
inputItem.value = keyCodeAliases[keyCode] ||
(keyCode >= 48 && keyCode <= 90 ? String.fromCharCode(keyCode) : `Key ${keyCode}`);
inputItem.keyCode = keyCode;
}
function add_shortcut() {
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="softer">Decrease volume</option>
<option value="louder">Increase volume</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)"/>
<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]
);
// If experimental features are already enabled, add the force select
const experimentalButton = document.getElementById("experimental");
if (experimentalButton && experimentalButton.disabled) {
const customValue = div.querySelector('.customValue');
const select = document.createElement('select');
select.className = 'customForce show';
select.innerHTML = `
<option value="false">Default behavior</option>
<option value="true">Override site keys</option>
`;
customValue.parentNode.insertBefore(select, customValue.nextSibling);
}
}
function createKeyBindings(item) {
const action = item.querySelector(".customDo").value;
const key = item.querySelector(".customKey").keyCode;
const value = Number(item.querySelector(".customValue").value);
const forceElement = item.querySelector(".customForce");
const force = forceElement ? forceElement.value : "false";
const predefined = !!item.id; //item.id ? true : false;
keyBindings.push({
action: action,
key: key,
value: value,
force: force,
predefined: predefined
});
}
// Validates settings before saving
function validate() {
var valid = true;
var status = document.getElementById("status");
var blacklist = document.getElementById("blacklist");
// Clear any existing timeout for validation errors
if (window.validationTimeout) {
clearTimeout(window.validationTimeout);
}
blacklist.value.split("\n").forEach((match) => {
match = match.replace(window.VSC.Constants.regStrip, "");
if (match.startsWith("/")) {
try {
var parts = match.split("/");
if (parts.length < 3)
throw "invalid regex";
var flags = parts.pop();
var regex = parts.slice(1).join("/");
var regexp = new RegExp(regex, flags);
} catch (err) {
status.textContent =
"Error: Invalid blacklist regex: \"" + match + "\". Unable to save. Try wrapping it in foward slashes.";
status.classList.add("show", "error");
valid = false;
// Auto-hide validation error after 5 seconds
window.validationTimeout = setTimeout(function () {
status.textContent = "";
status.classList.remove("show", "error");
}, 5000);
return;
}
}
});
return valid;
}
// Saves options using VideoSpeedConfig system
async function save_options() {
if (validate() === false) {
return;
}
var status = document.getElementById("status");
status.textContent = "Saving...";
status.classList.remove("success", "error");
status.classList.add("show");
try {
keyBindings = [];
Array.from(document.querySelectorAll(".customs")).forEach((item) =>
createKeyBindings(item)
);
// Ensure force values are boolean, not string
keyBindings = keyBindings.map(binding => ({
...binding,
force: Boolean(binding.force === "true" || binding.force === true)
}));
var rememberSpeed = document.getElementById("rememberSpeed").checked;
var forceLastSavedSpeed = document.getElementById("forceLastSavedSpeed").checked;
var audioBoolean = document.getElementById("audioBoolean").checked;
var startHidden = document.getElementById("startHidden").checked;
var controllerOpacity = Number(document.getElementById("controllerOpacity").value);
var controllerButtonSize = Number(document.getElementById("controllerButtonSize").value);
var logLevel = parseInt(document.getElementById("logLevel").value);
var blacklist = document.getElementById("blacklist").value;
// Ensure VideoSpeedConfig singleton is initialized
if (!window.VSC.videoSpeedConfig) {
window.VSC.videoSpeedConfig = new window.VSC.VideoSpeedConfig();
}
// Use VideoSpeedConfig to save settings
const settingsToSave = {
rememberSpeed: rememberSpeed,
forceLastSavedSpeed: forceLastSavedSpeed,
audioBoolean: audioBoolean,
startHidden: startHidden,
controllerOpacity: controllerOpacity,
controllerButtonSize: controllerButtonSize,
logLevel: logLevel,
keyBindings: keyBindings,
blacklist: blacklist.replace(window.VSC.Constants.regStrip, "")
};
// Save with optimistic UI (like old version)
await window.VSC.videoSpeedConfig.save(settingsToSave);
status.textContent = "Options saved";
status.classList.add("success");
setTimeout(function () {
status.textContent = "";
status.classList.remove("show", "success");
}, 2000);
} catch (error) {
// Only show error for actual storage failures
console.error("Failed to save options:", error);
status.textContent = "Error saving options: " + error.message;
status.classList.add("show", "error");
setTimeout(function () {
status.textContent = "";
status.classList.remove("show", "error");
}, 3000);
}
}
// Restores options using VideoSpeedConfig system
async function restore_options() {
try {
// Ensure VideoSpeedConfig singleton is initialized
if (!window.VSC.videoSpeedConfig) {
window.VSC.videoSpeedConfig = new window.VSC.VideoSpeedConfig();
}
// Load settings using VideoSpeedConfig
await window.VSC.videoSpeedConfig.load();
const storage = window.VSC.videoSpeedConfig.settings;
document.getElementById("rememberSpeed").checked = storage.rememberSpeed;
document.getElementById("forceLastSavedSpeed").checked = storage.forceLastSavedSpeed;
document.getElementById("audioBoolean").checked = storage.audioBoolean;
document.getElementById("startHidden").checked = storage.startHidden;
document.getElementById("controllerOpacity").value = storage.controllerOpacity;
document.getElementById("controllerButtonSize").value = storage.controllerButtonSize;
document.getElementById("logLevel").value = storage.logLevel;
document.getElementById("blacklist").value = storage.blacklist;
// Process key bindings
const keyBindings = storage.keyBindings || window.VSC.Constants.DEFAULT_SETTINGS.keyBindings;
for (let i in keyBindings) {
var item = keyBindings[i];
if (item.predefined) {
// Handle predefined shortcuts
if (item["action"] == "display" && typeof item["key"] === "undefined") {
item["key"] = 86; // V
}
if (window.VSC.Constants.CUSTOM_ACTIONS_NO_VALUES.includes(item["action"])) {
const valueInput = document.querySelector("#" + item["action"] + " .customValue");
if (valueInput) {
valueInput.style.display = "none";
}
}
const keyInput = document.querySelector("#" + item["action"] + " .customKey");
const valueInput = document.querySelector("#" + item["action"] + " .customValue");
const forceInput = document.querySelector("#" + item["action"] + " .customForce");
if (keyInput) {
updateCustomShortcutInputText(keyInput, item["key"]);
}
if (valueInput) {
valueInput.value = item["value"];
}
if (forceInput) {
forceInput.value = String(item["force"]);
}
} else {
// Handle custom shortcuts
add_shortcut();
const dom = document.querySelector(".customs:last-of-type");
dom.querySelector(".customDo").value = item["action"];
if (window.VSC.Constants.CUSTOM_ACTIONS_NO_VALUES.includes(item["action"])) {
const valueInput = dom.querySelector(".customValue");
if (valueInput) {
valueInput.style.display = "none";
}
}
updateCustomShortcutInputText(
dom.querySelector(".customKey"),
item["key"]
);
dom.querySelector(".customValue").value = item["value"];
// If force value exists in settings but element doesn't exist, create it
if (item["force"] !== undefined && !dom.querySelector(".customForce")) {
const customValue = dom.querySelector('.customValue');
const select = document.createElement('select');
select.className = 'customForce'; // Don't add 'show' class initially
select.innerHTML = `
<option value="false">Default behavior</option>
<option value="true">Override site keys</option>
`;
select.value = String(item["force"]);
customValue.parentNode.insertBefore(select, customValue.nextSibling);
} else {
const forceSelect = dom.querySelector(".customForce");
if (forceSelect) {
forceSelect.value = String(item["force"]);
}
}
}
}
// Check if any keybindings have force property set, if so, show experimental features
const hasExperimentalFeatures = keyBindings.some(kb => kb.force !== undefined && kb.force !== false);
if (hasExperimentalFeatures) {
show_experimental();
}
} catch (error) {
console.error("Failed to restore options:", error);
document.getElementById("status").textContent = "Error loading options: " + error.message;
document.getElementById("status").classList.add("show", "error");
setTimeout(function () {
document.getElementById("status").textContent = "";
document.getElementById("status").classList.remove("show", "error");
}, 3000);
}
}
async function restore_defaults() {
try {
var status = document.getElementById("status");
status.textContent = "Restoring defaults...";
status.classList.remove("success", "error");
status.classList.add("show");
// Clear all storage
await window.VSC.StorageManager.clear();
// Ensure VideoSpeedConfig singleton is initialized
if (!window.VSC.videoSpeedConfig) {
window.VSC.videoSpeedConfig = new window.VSC.VideoSpeedConfig();
}
// Then save fresh defaults
await window.VSC.videoSpeedConfig.save(window.VSC.Constants.DEFAULT_SETTINGS);
// Remove custom shortcuts from UI
document
.querySelectorAll(".removeParent")
.forEach((button) => button.click());
// Reload the options page
await restore_options();
status.textContent = "Default options restored";
status.classList.add("success");
setTimeout(function () {
status.textContent = "";
status.classList.remove("show", "success");
}, 2000);
} catch (error) {
console.error("Failed to restore defaults:", error);
status.textContent = "Error restoring defaults: " + error.message;
status.classList.add("show", "error");
setTimeout(function () {
status.textContent = "";
status.classList.remove("show", "error");
}, 3000);
}
}
function show_experimental() {
const button = document.getElementById("experimental");
const customRows = document.querySelectorAll('.row.customs');
const advancedRows = document.querySelectorAll('.row.advanced-feature');
// Show advanced feature rows
advancedRows.forEach((row) => {
row.classList.add('show');
});
// Create the select template
const createForceSelect = () => {
const select = document.createElement('select');
select.className = 'customForce show';
select.innerHTML = `
<option value="false">Allow event propagation</option>
<option value="true">Disable event propagation</option>
`;
return select;
};
// Add select to each row
customRows.forEach((row) => {
const existingSelect = row.querySelector('.customForce');
if (!existingSelect) {
// Create new select if it doesn't exist
const customValue = row.querySelector('.customValue');
const newSelect = createForceSelect();
// Check if this row has saved force value
const rowId = row.id;
if (rowId && window.VSC.videoSpeedConfig && window.VSC.videoSpeedConfig.settings.keyBindings) {
// For predefined shortcuts
const savedBinding = window.VSC.videoSpeedConfig.settings.keyBindings.find(kb => kb.action === rowId);
if (savedBinding && savedBinding.force !== undefined) {
newSelect.value = String(savedBinding.force);
}
} else if (!rowId) {
// For custom shortcuts, try to find the force value from the current keyBindings array
const rowIndex = Array.from(row.parentElement.querySelectorAll('.row.customs:not([id])')).indexOf(row);
const customBindings = window.VSC.videoSpeedConfig?.settings.keyBindings?.filter(kb => !kb.predefined) || [];
if (customBindings[rowIndex] && customBindings[rowIndex].force !== undefined) {
newSelect.value = String(customBindings[rowIndex].force);
}
}
// Insert after the customValue input
if (customValue) {
customValue.parentNode.insertBefore(newSelect, customValue.nextSibling);
}
} else {
// If it already exists, just show it
existingSelect.classList.add('show');
}
});
// Update button text to indicate the feature is now enabled
button.textContent = "Advanced features enabled";
button.disabled = true;
}
// Create debounced save function to prevent rapid saves
const debouncedSave = debounce(save_options, 300);
document.addEventListener("DOMContentLoaded", async function () {
// Optional: Set up storage error monitoring for debugging/telemetry
window.VSC.StorageManager.onError((error, data) => {
// Log to console for debugging, could also send telemetry
console.warn('Storage operation failed:', error.message, data);
});
await restore_options();
// Disable action dropdowns for predefined shortcuts
document.querySelectorAll('.row.customs[id] .customDo').forEach(select => {
select.disabled = true;
});
document.getElementById("save").addEventListener("click", async (e) => {
e.preventDefault();
await save_options();
});
document.getElementById("add").addEventListener("click", add_shortcut);
document.getElementById("restore").addEventListener("click", async (e) => {
e.preventDefault();
await restore_defaults();
});
document.getElementById("experimental").addEventListener("click", show_experimental);
// About and feedback button event listeners
document.getElementById("about").addEventListener("click", function () {
window.open("https://github.com/igrigorik/videospeed");
});
document.getElementById("feedback").addEventListener("click", function () {
window.open("https://github.com/igrigorik/videospeed/issues");
});
function eventCaller(event, className, funcName) {
if (!event.target.classList.contains(className)) {
return;
}
funcName(event);
}
document.addEventListener("keypress", (event) => {
eventCaller(event, "customValue", inputFilterNumbersOnly);
});
document.addEventListener("focus", (event) => {
eventCaller(event, "customKey", inputFocus);
});
document.addEventListener("blur", (event) => {
eventCaller(event, "customKey", inputBlur);
});
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 () {
const valueInput = event.target.nextElementSibling.nextElementSibling;
if (window.VSC.Constants.CUSTOM_ACTIONS_NO_VALUES.includes(event.target.value)) {
valueInput.style.display = "none";
valueInput.value = 0;
} else {
valueInput.style.display = "inline-block";
}
});
});
});
================================================
FILE: src/ui/popup/popup.css
================================================
/* Material Design Variables */
:root {
/* Light theme */
--md-surface: #ffffff;
--md-surface-variant: #f3f3f3;
--md-surface-container: #fafafa;
--md-on-surface: #1c1b1f;
--md-on-surface-variant: #49454f;
--md-primary: #6750a4;
--md-primary-container: #eaddff;
--md-on-primary: #ffffff;
--md-on-primary-container: #21005d;
--md-outline: #79747e;
--md-outline-variant: #cac4d0;
--md-shadow: rgba(0, 0, 0, 0.1);
--md-shadow-2: rgba(0, 0, 0, 0.15);
--md-shadow-3: rgba(0, 0, 0, 0.2);
/* Power button states */
--power-enabled: #34a853;
--power-disabled: #ea4335;
--power-enabled-bg: rgba(52, 168, 83, 0.08);
--power-disabled-bg: rgba(234, 67, 53, 0.08);
}
@media (prefers-color-scheme: dark) {
:root {
/* Dark theme */
--md-surface: #131316;
--md-surface-variant: #2b2930;
--md-surface-container: #1f1f23;
--md-on-surface: #e6e0e9;
--md-on-surface-variant: #cac4cf;
--md-primary: #d0bcff;
--md-primary-container: #4f378b;
--md-on-primary: #371e73;
--md-on-primary-container: #eaddff;
--md-outline: #938f99;
--md-outline-variant: #49454f;
--md-shadow: rgba(0, 0, 0, 0.3);
--md-shadow-2: rgba(0, 0, 0, 0.4);
--md-shadow-3: rgba(0, 0, 0, 0.5);
/* Power button states for dark mode */
--power-enabled: #5bb974;
--power-disabled: #f28b82;
--power-enabled-bg: rgba(91, 185, 116, 0.12);
--power-disabled-bg: rgba(242, 139, 130, 0.12);
}
}
/* Reset and base styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: var(--md-surface);
color: var(--md-on-surface);
min-width: 280px;
width: 280px;
}
/* Popup container */
.popup-container {
padding: 0;
background-color: var(--md-surface);
}
/* Footer */
.footer {
background-color: var(--md-surface-container);
border-top: 1px solid var(--md-outline-variant);
}
.footer-content {
display: flex;
align-items: center;
padding: 8px 20px;
position: relative;
}
.footer-controls {
display: flex;
gap: 4px;
margin-left: auto;
}
/* Icon buttons */
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 20px;
background-color: transparent;
color: var(--md-on-surface-variant);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
position: relative;
overflow: hidden;
}
.icon-btn:hover {
background-color: var(--md-surface-variant);
color: var(--md-on-surface);
}
.icon-btn:active {
transform: scale(0.95);
}
/* Power button specific styles */
#disable.power-btn {
color: var(--power-enabled);
background-color: transparent;
}
#disable.power-btn.disabled {
color: var(--power-disabled);
background-color: transparent;
}
#disable.power-btn:hover {
background-color: var(--md-surface-variant);
color: var(--power-enabled);
}
#disable.power-btn.disabled:hover {
background-color: var(--md-surface-variant);
color: var(--power-disabled);
}
/* Settings button */
.settings-btn:hover {
background-color: var(--md-primary-container);
color: var(--md-on-primary-container);
}
/* Speed section */
.speed-section {
padding: 20px 20px 16px 20px;
}
/* Speed controls */
.speed-controls {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
margin-bottom: 20px;
}
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
border: 1px solid var(--md-outline-variant);
border-radius: 12px;
background-color: var(--md-surface-variant);
color: var(--md-on-surface);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
position: relative;
overflow: hidden;
}
.control-btn:hover {
background-color: var(--md-primary-container);
color: var(--md-on-primary-container);
border-color: var(--md-primary);
box-shadow: 0 2px 4px var(--md-shadow);
transform: translateY(-1px);
}
.control-btn:active {
transform: translateY(0) scale(0.98);
box-shadow: 0 1px 2px var(--md-shadow);
}
/* Reset button special styling */
.reset-btn {
background-color: var(--md-primary);
color: var(--md-on-primary);
border-color: var(--md-primary);
font-size: 14px;
font-weight: 600;
}
.reset-btn:hover {
background-color: var(--md-primary);
color: var(--md-on-primary);
box-shadow: 0 4px 8px var(--md-shadow-2);
transform: translateY(-2px);
}
/* Speed presets */
.speed-presets {
margin-top: 8px;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.preset-btn {
padding: 10px 4px;
border: 1px solid var(--md-outline-variant);
border-radius: 8px;
background-color: var(--md-surface-variant);
color: var(--md-on-surface);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
position: relative;
overflow: hidden;
}
.preset-btn:hover {
background-color: var(--md-primary-container);
color: var(--md-on-primary-container);
border-color: var(--md-primary);
transform: translateY(-1px);
box-shadow: 0 2px 4px var(--md-shadow);
}
.preset-btn:active {
transform: translateY(0) scale(0.95);
box-shadow: 0 1px 2px var(--md-shadow);
}
/* Active state for preset buttons */
.preset-btn.active {
background-color: var(--md-primary);
color: var(--md-on-primary);
border-color: var(--md-primary);
box-shadow: 0 2px 4px var(--md-shadow);
}
/* Status */
.status {
padding: 0;
background-color: transparent;
font-size: 12px;
color: var(--md-on-surface-variant);
text-align: left;
animation: slideIn 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.status.error {
background-color: var(--power-disabled-bg);
color: var(--power-disabled);
}
.status.success {
background-color: var(--power-enabled-bg);
color: var(--power-enabled);
}
/* Hide utility */
.hide {
display: none !important;
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Ripple effect for buttons */
.control-btn::before,
.preset-btn::before,
.icon-btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background-color: currentColor;
opacity: 0.1;
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.control-btn:active::before,
.preset-btn:active::before,
.icon-btn:active::before {
width: 300px;
height: 300px;
}
/* Focus styles for accessibility */
.control-btn:focus-visible,
.preset-btn:focus-visible,
.icon-btn:focus-visible {
outline: 2px solid var(--md-primary);
outline-offset: 2px;
}
/* Responsive adjustments */
@media (max-width: 320px) {
body {
min-width: 260px;
width: 260px;
}
.popup-container {
padding: 0;
}
.header {
padding: 12px 16px;
}
.speed-section {
padding: 16px;
}
}
================================================
FILE: src/ui/popup/popup.html
================================================
<!DOCTYPE html>
<html>
<head>
<title>Video Speed Controller</title>
<link rel="stylesheet" href="popup.css" />
<script src="popup.js"></script>
</head>
<body>
<div class="popup-container">
<!-- Speed Control Section (now at top) -->
<div class="speed-section">
<!-- Fine Controls -->
<div class="speed-controls">
<button id="speed-decrease" class="control-btn" data-delta="-0.1">
<span>-0.1</span>
</button>
<button id="speed-reset" class="control-btn reset-btn">1.0</button>
<button id="speed-increase" class="control-btn" data-delta="0.1">
<span>+0.1</span>
</button>
</div>
<!-- Speed Presets -->
<div class="speed-presets">
<div class="preset-grid">
<button class="preset-btn" data-speed="0.5">0.5</button>
<button class="preset-btn" data-speed="0.75">0.75</button>
<button class="preset-btn" data-speed="1.0">1</button>
<button class="preset-btn" data-speed="1.25">1.25</button>
<button class="preset-btn" data-speed="1.5">1.5</button>
<button class="preset-btn" data-speed="1.75">1.75</button>
<button class="preset-btn" data-speed="2.0">2</button>
<button class="preset-btn" data-speed="2.5">2.5</button>
</div>
</div>
</div>
<!-- Footer with controls and status -->
<div class="footer">
<div class="footer-content">
<div id="status" class="status hide"></div>
<div class="footer-controls">
<button id="disable" class="icon-btn power-btn" title="Toggle Extension">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M12 2a2 2 0 0 1 2 2v8a2 2 0 0 1-4 0V4a2 2 0 0 1 2-2zm0 3v7" />
<path fill="currentColor"
d="M12 22c-5.522 0-10-4.477-10-10 0-4.418 2.865-8.166 7-9.542v2.084C5.132 5.91 3 8.735 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9c0-3.265-2.132-6.09-6-7.458V2.458C19.135 3.834 22 7.582 22 12c0 5.523-4.478 10-10 10z" />
</svg>
</button>
<button id="config" class="icon-btn settings-btn" title="Settings">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor"
d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.22,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.68 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z" />
</svg>
</button>
</div>
</div>
</div>
</div>
</body>
</html>
================================================
FILE: src/ui/popup/popup.js
================================================
// Message type constants
const MessageTypes = {
SET_SPEED: 'VSC_SET_SPEED',
ADJUST_SPEED: 'VSC_ADJUST_SPEED',
RESET_SPEED: 'VSC_RESET_SPEED',
TOGGLE_DISPLAY: 'VSC_TOGGLE_DISPLAY'
};
document.addEventListener("DOMContentLoaded", function () {
// Load settings and initialize speed controls
loadSettingsAndInitialize();
// Settings button event listener
document.querySelector("#config").addEventListener("click", function () {
chrome.runtime.openOptionsPage();
});
// Power button toggle event listener
document.querySelector("#disable").addEventListener("click", function () {
// Toggle based on current state
const isCurrentlyEnabled = !this.classList.contains("disabled");
toggleEnabled(!isCurrentlyEnabled, settingsSavedReloadMessage);
});
// Initialize enabled state
chrome.storage.sync.get({ enabled: true }, function (storage) {
toggleEnabledUI(storage.enabled);
});
function toggleEnabled(enabled, callback) {
chrome.storage.sync.set(
{
enabled: enabled
},
function () {
toggleEnabledUI(enabled);
if (callback) callback(enabled);
}
);
}
function toggleEnabledUI(enabled) {
const disableBtn = document.querySelector("#disable");
disableBtn.classList.toggle("disabled", !enabled);
// Update tooltip
disableBtn.title = enabled ? "Disable Extension" : "Enable Extension";
const suffix = enabled ? "" : "_disabled";
chrome.action.setIcon({
path: {
"19": chrome.runtime.getURL(`assets/icons/icon19${suffix}.png`),
"38": chrome.runtime.getURL(`assets/icons/icon38${suffix}.png`),
"48": chrome.runtime.getURL(`assets/icons/icon48${suffix}.png`)
}
});
// Notify background script of state change
chrome.runtime.sendMessage({ type: 'EXTENSION_TOGGLE', enabled: enabled });
}
function settingsSavedReloadMessage(enabled) {
setStatusMessage(
`${enabled ? "Enabled" : "Disabled"}. Reload page.`
);
}
function setStatusMessage(str) {
const status_element = document.querySelector("#status");
status_element.classList.toggle("hide", false);
status_element.innerText = str;
}
// Load settings and initialize UI
function loadSettingsAndInitialize() {
chrome.storage.sync.get(null, function (storage) {
// Find the step values from keyBindings
let slowerStep = 0.1;
let fasterStep = 0.1;
let resetSpeed = 1.0;
if (storage.keyBindings && Array.isArray(storage.keyBindings)) {
const slowerBinding = storage.keyBindings.find(kb => kb.action === "slower");
const fasterBinding = storage.keyBindings.find(kb => kb.action === "faster");
const fastBinding = storage.keyBindings.find(kb => kb.action === "fast");
if (slowerBinding && typeof slowerBinding.value === 'number') {
slowerStep = slowerBinding.value;
}
if (fasterBinding && typeof fasterBinding.value === 'number') {
fasterStep = fasterBinding.value;
}
if (fastBinding && typeof fastBinding.value === 'number') {
resetSpeed = fastBinding.value;
}
}
// Update the UI with dynamic values
updateSpeedControlsUI(slowerStep, fasterStep, resetSpeed);
// Initialize event listeners
initializeSpeedControls(slowerStep, fasterStep);
});
}
function updateSpeedControlsUI(slowerStep, fasterStep, resetSpeed) {
// Update decrease button
const decreaseBtn = document.querySelector("#speed-decrease");
if (decreaseBtn) {
decreaseBtn.dataset.delta = -slowerStep;
decreaseBtn.querySelector("span").textContent = `-${slowerStep}`;
}
// Update increase button
const increaseBtn = document.querySelector("#speed-increase");
if (increaseBtn) {
increaseBtn.dataset.delta = fasterStep;
increaseBtn.querySelector("span").textContent = `+${fasterStep}`;
}
// Update reset button
const resetBtn = document.querySelector("#speed-reset");
if (resetBtn) {
resetBtn.textContent = resetSpeed.toString();
}
}
// Speed Control Functions
function initializeSpeedControls(slowerStep, fasterStep) {
// Set up speed control button listeners
document.querySelector("#speed-decrease").addEventListener("click", function () {
const delta = parseFloat(this.dataset.delta);
adjustSpeed(delta);
});
document.querySelector("#speed-increase").addEventListener("click", function () {
const delta = parseFloat(this.dataset.delta);
adjustSpeed(delta);
});
document.querySelector("#speed-reset").addEventListener("click", function () {
// Set directly to preferred speed instead of toggling
const preferredSpeed = parseFloat(this.textContent);
setSpeed(preferredSpeed);
});
// Set up preset button listeners
document.querySelectorAll(".preset-btn").forEach(btn => {
btn.addEventListener("click", function () {
const speed = parseFloat(this.dataset.speed);
setSpeed(speed);
});
});
}
function setSpeed(speed) {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (tabs[0]) {
chrome.tabs.sendMessage(tabs[0].id, {
type: MessageTypes.SET_SPEED,
payload: { speed: speed }
});
}
});
}
function adjustSpeed(delta) {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (tabs[0]) {
chrome.tabs.sendMessage(tabs[0].id, {
type: MessageTypes.ADJUST_SPEED,
payload: { delta: delta }
});
}
});
}
function resetSpeed() {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (tabs[0]) {
chrome.tabs.sendMessage(tabs[0].id, {
type: MessageTypes.RESET_SPEED
});
}
});
}
});
================================================
FILE: src/ui/shadow-dom.js
================================================
/**
* Shadow DOM creation and management
*/
window.VSC = window.VSC || {};
class ShadowDOMManager {
/**
* Create shadow DOM for video controller
* @param {HTMLElement} wrapper - Wrapper element
* @param {Object} options - Configuration options
* @returns {ShadowRoot} Created shadow root
*/
static createShadowDOM(wrapper, options = {}) {
const { top = '0px', left = '0px', speed = '1.00', opacity = 0.3, buttonSize = 14 } = options;
const shadow = wrapper.attachShadow({ mode: 'open' });
// Create style element with embedded CSS for immediate styling
const style = document.createElement('style');
style.textContent = `
* {
line-height: 1.8em;
font-family: sans-serif;
font-size: 13px;
}
:host(:hover) #controls {
display: inline-block;
}
/* Hide shadow DOM content for different hiding scenarios */
:host(.vsc-hidden) #controller,
:host(.vsc-nosource) #controller {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
}
/* Override hiding for manual controllers (unless explicitly hidden) */
:host(.vsc-manual:not(.vsc-hidden)) #controller {
display: block !important;
visibility: visible !important;
opacity: ${opacity} !important;
}
/* Show shadow DOM content when host has vsc-show class (highest priority) */
:host(.vsc-show) #controller {
display: block !important;
visibility: visible !important;
opacity: ${opacity} !important;
}
#controller {
position: absolute;
top: 0;
left: 0;
background: black;
color: white;
border-radius: 6px;
padding: 4px;
margin: 10px 10px 10px 15px;
cursor: default;
z-index: 9999999;
white-space: nowrap;
}
#controller:hover {
opacity: 0.7;
}
#controller:hover>.draggable {
margin-right: 0.8em;
}
#controls {
display: none;
vertical-align: middle;
}
#controller.dragging {
cursor: -webkit-grabbing;
opacity: 0.7;
}
#controller.dragging #controls {
display: inline-block;
}
.draggable {
cursor: -webkit-grab;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8em;
height: 1.4em;
text-align: center;
vertical-align: middle;
box-sizing: border-box;
}
.draggable:active {
cursor: -webkit-grabbing;
}
button {
opacity: 1;
cursor: pointer;
color: black;
background: white;
font-weight: normal;
border-radius: 5px;
padding: 1px 5px 3px 5px;
font-size: inherit;
line-height: inherit;
border: 0px solid white;
font-family: "Lucida Console", Monaco, monospac
gitextract_25y6uuoy/
├── .eslintrc.json
├── .gitignore
├── .pre-commit-config.yaml
├── CONTRIBUTING.md
├── LICENSE
├── PRIVACY.md
├── README.md
├── manifest.json
├── package.json
├── scripts/
│ └── build.mjs
├── src/
│ ├── background.js
│ ├── content/
│ │ ├── inject.js
│ │ ├── injection-bridge.js
│ │ └── injector-simplified.js
│ ├── core/
│ │ ├── action-handler.js
│ │ ├── settings.js
│ │ ├── state-manager.js
│ │ ├── storage-manager.js
│ │ └── video-controller.js
│ ├── entries/
│ │ ├── content-entry.js
│ │ └── inject-entry.js
│ ├── observers/
│ │ ├── media-observer.js
│ │ └── mutation-observer.js
│ ├── site-handlers/
│ │ ├── amazon-handler.js
│ │ ├── apple-handler.js
│ │ ├── base-handler.js
│ │ ├── facebook-handler.js
│ │ ├── index.js
│ │ ├── netflix-handler.js
│ │ ├── scripts/
│ │ │ └── netflix.js
│ │ └── youtube-handler.js
│ ├── styles/
│ │ └── inject.css
│ ├── ui/
│ │ ├── controls.js
│ │ ├── drag-handler.js
│ │ ├── options/
│ │ │ ├── options.css
│ │ │ ├── options.html
│ │ │ └── options.js
│ │ ├── popup/
│ │ │ ├── popup.css
│ │ │ ├── popup.html
│ │ │ └── popup.js
│ │ ├── shadow-dom.js
│ │ └── vsc-controller-element.js
│ └── utils/
│ ├── blacklist.js
│ ├── constants.js
│ ├── debug-helper.js
│ ├── dom-utils.js
│ ├── event-manager.js
│ └── logger.js
└── tests/
├── e2e/
│ ├── basic.e2e.js
│ ├── display-toggle.e2e.js
│ ├── e2e-utils.js
│ ├── icon.e2e.js
│ ├── manual-test-guide.md
│ ├── run-e2e.js
│ ├── settings-injection.e2e.js
│ ├── test-video.html
│ ├── validate-extension.js
│ └── youtube.e2e.js
├── fixtures/
│ └── test-page.html
├── helpers/
│ ├── chrome-mock.js
│ ├── module-loader.js
│ └── test-utils.js
├── integration/
│ ├── blacklist-blocking.test.js
│ ├── module-integration.test.js
│ ├── state-manager-integration.test.js
│ └── ui-to-storage-flow.test.js
├── run-tests.js
├── test-config.js
└── unit/
├── content/
│ ├── content-entry.test.js
│ ├── hydration-fix.test.js
│ └── inject.test.js
├── core/
│ ├── action-handler.test.js
│ ├── f-keys.test.js
│ ├── icon-integration.test.js
│ ├── keyboard-shortcuts-saving.test.js
│ ├── settings.test.js
│ └── video-controller.test.js
├── observers/
│ ├── audio-size-handling.test.js
│ └── mutation-observer.test.js
└── utils/
├── blacklist-regex.test.js
├── event-manager.test.js
└── recursive-shadow-dom.test.js
SYMBOL INDEX (294 symbols across 50 files)
FILE: scripts/build.mjs
function copyStaticFiles (line 23) | async function copyStaticFiles() {
function build (line 57) | async function build() {
FILE: src/background.js
function updateIcon (line 5) | async function updateIcon(enabled) {
function initializeIcon (line 24) | async function initializeIcon() {
function migrateConfig (line 39) | async function migrateConfig() {
FILE: src/content/inject.js
class VideoSpeedExtension (line 6) | class VideoSpeedExtension {
method constructor (line 7) | constructor() {
method initialize (line 19) | async initialize() {
method initializeDocument (line 66) | initializeDocument(document) {
method deferExpensiveOperations (line 91) | deferExpensiveOperations(document) {
method deferredMediaScan (line 121) | deferredMediaScan(document) {
method scheduleComprehensiveScan (line 157) | scheduleComprehensiveScan(document) {
method applyDomainStyles (line 184) | applyDomainStyles(document) {
method setupObservers (line 198) | setupObservers() {
method onVideoFound (line 216) | onVideoFound(video, parent) {
method onVideoRemoved (line 254) | onVideoRemoved(video) {
method setupDocumentCSS (line 269) | setupDocumentCSS(document) {
FILE: src/content/injection-bridge.js
function injectScript (line 11) | async function injectScript(scriptPath) {
function setupMessageBridge (line 31) | function setupMessageBridge() {
FILE: src/content/injector-simplified.js
function injectScript (line 11) | async function injectScript(scriptPath) {
function setupMessageBridge (line 31) | function setupMessageBridge() {
FILE: src/core/action-handler.js
class ActionHandler (line 8) | class ActionHandler {
method constructor (line 9) | constructor(config, eventManager) {
method runAction (line 20) | runAction(action, value, e) {
method executeAction (line 61) | executeAction(action, value, video, e) {
method seek (line 187) | seek(video, seekSeconds) {
method pause (line 196) | pause(video) {
method resetSpeed (line 211) | resetSpeed(video, target) {
method muted (line 247) | muted(video) {
method volumeUp (line 256) | volumeUp(video, value) {
method volumeDown (line 265) | volumeDown(video, value) {
method setMark (line 273) | setMark(video) {
method jumpToMark (line 282) | jumpToMark(video) {
method blinkController (line 294) | blinkController(controller, duration) {
method isAudioController (line 331) | isAudioController(controller) {
method adjustSpeed (line 353) | adjustSpeed(video, value, options = {}) {
method _adjustSpeedInternal (line 382) | _adjustSpeedInternal(video, value, options) {
method getPreferredSpeed (line 429) | getPreferredSpeed(video) {
method setSpeed (line 440) | setSpeed(video, speed, source = 'internal') {
FILE: src/core/settings.js
class VideoSpeedConfig (line 8) | class VideoSpeedConfig {
method constructor (line 9) | constructor() {
method load (line 20) | async load() {
method save (line 66) | async save(newSettings = {}) {
method getKeyBinding (line 113) | getKeyBinding(action, property = 'value') {
method setKeyBinding (line 128) | setKeyBinding(action, value) {
method ensureDisplayBinding (line 155) | ensureDisplayBinding() {
FILE: src/core/state-manager.js
class VSCStateManager (line 8) | class VSCStateManager {
method constructor (line 9) | constructor() {
method registerController (line 20) | registerController(controller) {
method unregisterController (line 43) | unregisterController(controllerId) {
method getAllMediaElements (line 54) | getAllMediaElements() {
method getMediaByControllerId (line 76) | getMediaByControllerId(controllerId) {
method getFirstMedia (line 85) | getFirstMedia() {
method hasControllers (line 94) | hasControllers() {
method removeController (line 102) | removeController(controllerId) {
method getControlledElements (line 110) | getControlledElements() {
FILE: src/core/storage-manager.js
class StorageManager (line 9) | class StorageManager {
method onError (line 16) | static onError(callback) {
method get (line 25) | static async get(defaults = {}) {
method set (line 67) | static async set(data) {
method remove (line 111) | static async remove(keys) {
method clear (line 144) | static async clear() {
method onChanged (line 175) | static onChanged(callback) {
FILE: src/core/video-controller.js
class VideoController (line 8) | class VideoController {
method constructor (line 9) | constructor(target, parent, config, actionHandler, shouldStartHidden =...
method initializeSpeed (line 57) | initializeSpeed() {
method getTargetSpeed (line 75) | getTargetSpeed(media = this.video) {
method initializeControls (line 94) | initializeControls() {
method insertIntoDOM (line 163) | insertIntoDOM(document, wrapper) {
method setupEventHandlers (line 198) | setupEventHandlers() {
method setupMutationObserver (line 223) | setupMutationObserver() {
method remove (line 249) | remove() {
method generateControllerId (line 287) | generateControllerId(target) {
method isVideoVisible (line 306) | isVideoVisible() {
method updateVisibility (line 331) | updateVisibility() {
FILE: src/entries/content-entry.js
function init (line 9) | async function init() {
FILE: src/observers/media-observer.js
class MediaElementObserver (line 7) | class MediaElementObserver {
method constructor (line 8) | constructor(config, siteHandler) {
method scanForMedia (line 18) | scanForMedia(document) {
method scanForMediaLight (line 65) | scanForMediaLight(document) {
method scanIframes (line 99) | scanIframes(document) {
method scanSiteSpecificContainers (line 125) | scanSiteSpecificContainers(document) {
method scanAll (line 150) | scanAll(document) {
method isValidMediaElement (line 177) | isValidMediaElement(media) {
method shouldStartHidden (line 206) | shouldStartHidden(media) {
method findControllerParent (line 245) | findControllerParent(media) {
FILE: src/observers/mutation-observer.js
class VideoMutationObserver (line 7) | class VideoMutationObserver {
method constructor (line 8) | constructor(config, onVideoFound, onVideoRemoved, mediaObserver) {
method start (line 21) | start(document) {
method processMutations (line 47) | processMutations(mutations) {
method processChildListMutation (line 65) | processChildListMutation(mutation) {
method processAttributeMutation (line 98) | processAttributeMutation(mutation) {
method handleVisibilityChanges (line 133) | handleVisibilityChanges(element) {
method recheckVideoElement (line 158) | recheckVideoElement(video) {
method checkForVideoAndShadowRoot (line 189) | checkForVideoAndShadowRoot(node, parent, added) {
method processNodeChildren (line 218) | processNodeChildren(node, parent, added) {
method observeShadowRoot (line 243) | observeShadowRoot(shadowRoot) {
method onDocumentReplaced (line 273) | onDocumentReplaced() {
method stop (line 281) | stop() {
FILE: src/site-handlers/amazon-handler.js
class AmazonHandler (line 7) | class AmazonHandler extends window.VSC.BaseSiteHandler {
method matches (line 12) | static matches() {
method getControllerPosition (line 27) | getControllerPosition(parent, video) {
method shouldIgnoreVideo (line 47) | shouldIgnoreVideo(video) {
method getVideoContainerSelectors (line 62) | getVideoContainerSelectors() {
FILE: src/site-handlers/apple-handler.js
class AppleHandler (line 7) | class AppleHandler extends window.VSC.BaseSiteHandler {
method matches (line 12) | static matches() {
method getControllerPosition (line 22) | getControllerPosition(parent, _video) {
method getVideoContainerSelectors (line 35) | getVideoContainerSelectors() {
method detectSpecialVideos (line 44) | detectSpecialVideos(document) {
FILE: src/site-handlers/base-handler.js
class BaseSiteHandler (line 7) | class BaseSiteHandler {
method constructor (line 8) | constructor() {
method matches (line 16) | static matches() {
method getControllerPosition (line 26) | getControllerPosition(parent, _video) {
method handleSeek (line 40) | handleSeek(video, seekSeconds) {
method initialize (line 56) | initialize(_document) {
method cleanup (line 63) | cleanup() {
method shouldIgnoreVideo (line 72) | shouldIgnoreVideo(_video) {
method getVideoContainerSelectors (line 80) | getVideoContainerSelectors() {
method detectSpecialVideos (line 89) | detectSpecialVideos(_document) {
FILE: src/site-handlers/facebook-handler.js
class FacebookHandler (line 7) | class FacebookHandler extends window.VSC.BaseSiteHandler {
method matches (line 12) | static matches() {
method getControllerPosition (line 22) | getControllerPosition(parent, _video) {
method initialize (line 47) | initialize(document) {
method setupFacebookObserver (line 59) | setupFacebookObserver(document) {
method onNewVideosDetected (line 92) | onNewVideosDetected(videos) {
method shouldIgnoreVideo (line 103) | shouldIgnoreVideo(video) {
method getVideoContainerSelectors (line 116) | getVideoContainerSelectors() {
method cleanup (line 123) | cleanup() {
FILE: src/site-handlers/index.js
class SiteHandlerManager (line 7) | class SiteHandlerManager {
method constructor (line 8) | constructor() {
method getCurrentHandler (line 23) | getCurrentHandler() {
method detectHandler (line 35) | detectHandler() {
method initialize (line 51) | initialize(document) {
method getControllerPosition (line 62) | getControllerPosition(parent, video) {
method handleSeek (line 73) | handleSeek(video, seekSeconds) {
method shouldIgnoreVideo (line 83) | shouldIgnoreVideo(video) {
method getVideoContainerSelectors (line 92) | getVideoContainerSelectors() {
method detectSpecialVideos (line 102) | detectSpecialVideos(document) {
method cleanup (line 110) | cleanup() {
method refresh (line 120) | refresh() {
FILE: src/site-handlers/netflix-handler.js
class NetflixHandler (line 7) | class NetflixHandler extends window.VSC.BaseSiteHandler {
method matches (line 12) | static matches() {
method getControllerPosition (line 22) | getControllerPosition(parent, _video) {
method handleSeek (line 37) | handleSeek(video, seekSeconds) {
method initialize (line 62) | initialize(document) {
method shouldIgnoreVideo (line 77) | shouldIgnoreVideo(video) {
method getVideoContainerSelectors (line 89) | getVideoContainerSelectors() {
FILE: src/site-handlers/youtube-handler.js
class YouTubeHandler (line 7) | class YouTubeHandler extends window.VSC.BaseSiteHandler {
method matches (line 12) | static matches() {
method getControllerPosition (line 22) | getControllerPosition(parent, _video) {
method initialize (line 37) | initialize(document) {
method setupYouTubeCSS (line 48) | setupYouTubeCSS() {
method shouldIgnoreVideo (line 59) | shouldIgnoreVideo(video) {
method getVideoContainerSelectors (line 71) | getVideoContainerSelectors() {
method detectSpecialVideos (line 80) | detectSpecialVideos(document) {
method onPlayerStateChange (line 108) | onPlayerStateChange(_video) {
FILE: src/ui/controls.js
class ControlsManager (line 7) | class ControlsManager {
method constructor (line 8) | constructor(actionHandler, config) {
method setupControlEvents (line 18) | setupControlEvents(shadow, video) {
method setupDragHandler (line 30) | setupDragHandler(shadow) {
method setupButtonHandlers (line 49) | setupButtonHandlers(shadow) {
method setupWheelHandler (line 91) | setupWheelHandler(shadow, video) {
method setupClickPrevention (line 127) | setupClickPrevention(shadow) {
FILE: src/ui/drag-handler.js
class DragHandler (line 7) | class DragHandler {
method handleDrag (line 13) | static handleDrag(video, e) {
FILE: src/ui/options/options.js
function debounce (line 18) | function debounce(func, wait) {
constant BLACKLISTED_KEYCODES (line 33) | const BLACKLISTED_KEYCODES = [
function recordKeyPress (line 105) | function recordKeyPress(e) {
function inputFilterNumbersOnly (line 139) | function inputFilterNumbersOnly(e) {
function inputFocus (line 147) | function inputFocus(e) {
function inputBlur (line 151) | function inputBlur(e) {
function updateShortcutInputText (line 157) | function updateShortcutInputText(inputId, keyCode) {
function updateCustomShortcutInputText (line 164) | function updateCustomShortcutInputText(inputItem, keyCode) {
function add_shortcut (line 171) | function add_shortcut() {
function createKeyBindings (line 213) | function createKeyBindings(item) {
function validate (line 231) | function validate() {
function save_options (line 275) | async function save_options() {
function restore_options (line 347) | async function restore_options() {
function restore_defaults (line 455) | async function restore_defaults() {
function show_experimental (line 498) | function show_experimental() {
function eventCaller (line 600) | function eventCaller(event, className, funcName) {
FILE: src/ui/popup/popup.js
function toggleEnabled (line 30) | function toggleEnabled(enabled, callback) {
function toggleEnabledUI (line 42) | function toggleEnabledUI(enabled) {
function settingsSavedReloadMessage (line 62) | function settingsSavedReloadMessage(enabled) {
function setStatusMessage (line 68) | function setStatusMessage(str) {
function loadSettingsAndInitialize (line 75) | function loadSettingsAndInitialize() {
function updateSpeedControlsUI (line 106) | function updateSpeedControlsUI(slowerStep, fasterStep, resetSpeed) {
function initializeSpeedControls (line 129) | function initializeSpeedControls(slowerStep, fasterStep) {
function setSpeed (line 156) | function setSpeed(speed) {
function adjustSpeed (line 167) | function adjustSpeed(delta) {
function resetSpeed (line 178) | function resetSpeed() {
FILE: src/ui/shadow-dom.js
class ShadowDOMManager (line 7) | class ShadowDOMManager {
method createShadowDOM (line 14) | static createShadowDOM(wrapper, options = {}) {
method getController (line 199) | static getController(shadow) {
method getControls (line 208) | static getControls(shadow) {
method getSpeedIndicator (line 217) | static getSpeedIndicator(shadow) {
method getButtons (line 226) | static getButtons(shadow) {
method updateSpeedDisplay (line 235) | static updateSpeedDisplay(shadow, speed) {
method calculatePosition (line 247) | static calculatePosition(video) {
FILE: src/ui/vsc-controller-element.js
class VSCControllerElement (line 8) | class VSCControllerElement extends HTMLElement {
method constructor (line 9) | constructor() {
method connectedCallback (line 13) | connectedCallback() {
method disconnectedCallback (line 17) | disconnectedCallback() {
method register (line 22) | static register() {
FILE: src/utils/blacklist.js
function isBlacklisted (line 12) | function isBlacklisted(blacklist, href) {
FILE: src/utils/debug-helper.js
class DebugHelper (line 8) | class DebugHelper {
method constructor (line 9) | constructor() {
method enable (line 16) | enable() {
method checkMediaElements (line 44) | checkMediaElements() {
method checkShadowDOMMedia (line 102) | checkShadowDOMMedia() {
method checkControllers (line 130) | checkControllers() {
method testPopupCommunication (line 188) | testPopupCommunication() {
method testPopupMessageBridge (line 253) | testPopupMessageBridge() {
method forceShowControllers (line 285) | forceShowControllers() {
method forceShowAudioControllers (line 308) | forceShowAudioControllers() {
method getElementVisibility (line 340) | getElementVisibility(element) {
method monitorControllerChanges (line 364) | monitorControllerChanges() {
FILE: src/utils/dom-utils.js
function getChild (line 30) | function getChild(element, depth = 0) {
FILE: src/utils/event-manager.js
class EventManager (line 7) | class EventManager {
method constructor (line 8) | constructor(config, actionHandler) {
method setupEventListeners (line 23) | setupEventListeners(document) {
method setupKeyboardShortcuts (line 32) | setupKeyboardShortcuts(document) {
method handleKeydown (line 64) | handleKeydown(event) {
method hasActiveModifier (line 120) | hasActiveModifier(event) {
method isTypingContext (line 138) | isTypingContext(target) {
method setupRateChangeListener (line 148) | setupRateChangeListener(document) {
method handleRateChange (line 168) | handleRateChange(event) {
method refreshCoolDown (line 251) | refreshCoolDown() {
method showController (line 269) | showController(controller) {
method cleanup (line 299) | cleanup() {
FILE: src/utils/logger.js
class Logger (line 8) | class Logger {
method constructor (line 9) | constructor() {
method setVerbosity (line 19) | setVerbosity(level) {
method setDefaultLevel (line 27) | setDefaultLevel(level) {
method generateContext (line 36) | generateContext() {
method formatVideoId (line 49) | formatVideoId(video) {
method pushContext (line 68) | pushContext(context) {
method popContext (line 79) | popContext() {
method withContext (line 89) | withContext(context, fn) {
method log (line 103) | log(message, level) {
method error (line 138) | error(message) {
method warn (line 146) | warn(message) {
method info (line 154) | info(message) {
method debug (line 162) | debug(message) {
method verbose (line 170) | verbose(message) {
FILE: tests/e2e/basic.e2e.js
function runBasicE2ETests (line 19) | async function runBasicE2ETests() {
FILE: tests/e2e/display-toggle.e2e.js
function testDisplayToggle (line 12) | async function testDisplayToggle() {
function run (line 135) | async function run() {
FILE: tests/e2e/e2e-utils.js
function launchChromeWithExtension (line 23) | async function launchChromeWithExtension() {
function waitForExtension (line 112) | async function waitForExtension(page, timeout = 15000) {
function waitForVideo (line 205) | async function waitForVideo(page, selector = 'video', timeout = 15000) {
function waitForController (line 233) | async function waitForController(page, timeout = 10000) {
function getVideoSpeed (line 265) | async function getVideoSpeed(page, selector = 'video') {
function controlVideo (line 278) | async function controlVideo(page, action) {
function testKeyboardShortcut (line 324) | async function testKeyboardShortcut(page, key) {
function getControllerSpeedDisplay (line 344) | async function getControllerSpeedDisplay(page) {
function takeScreenshot (line 367) | async function takeScreenshot(page, filename) {
FILE: tests/e2e/icon.e2e.js
function testUltraSimplified (line 17) | async function testUltraSimplified() {
FILE: tests/e2e/run-e2e.js
function runE2ETests (line 24) | async function runE2ETests() {
FILE: tests/e2e/settings-injection.e2e.js
function runSettingsInjectionE2ETests (line 8) | async function runSettingsInjectionE2ETests() {
FILE: tests/e2e/validate-extension.js
function validateExtension (line 16) | function validateExtension() {
FILE: tests/e2e/youtube.e2e.js
constant YOUTUBE_TEST_URL (line 19) | const YOUTUBE_TEST_URL = 'https://www.youtube.com/watch?v=gGCJOTvECVQ';
function runYouTubeE2ETests (line 21) | async function runYouTubeE2ETests() {
FILE: tests/helpers/chrome-mock.js
function installChromeMock (line 88) | function installChromeMock() {
function cleanupChromeMock (line 95) | function cleanupChromeMock() {
function resetMockStorage (line 102) | function resetMockStorage() {
FILE: tests/helpers/module-loader.js
function loadCoreModules (line 10) | async function loadCoreModules() {
function loadInjectModules (line 51) | async function loadInjectModules() {
function loadMinimalModules (line 59) | async function loadMinimalModules() {
function loadObserverModules (line 69) | async function loadObserverModules() {
FILE: tests/helpers/test-utils.js
function createMockVideo (line 10) | function createMockVideo(options = {}) {
function createMockAudio (line 115) | function createMockAudio(options = {}) {
function createMockDOM (line 144) | function createMockDOM() {
function wait (line 164) | function wait(ms) {
function createMockEvent (line 174) | function createMockEvent(type, properties = {}) {
function createMockKeyboardEvent (line 187) | function createMockKeyboardEvent(type, keyCode, options = {}) {
class SimpleTestRunner (line 253) | class SimpleTestRunner {
method constructor (line 254) | constructor() {
method beforeEach (line 260) | beforeEach(fn) {
method afterEach (line 264) | afterEach(fn) {
method test (line 268) | test(name, fn) {
method run (line 272) | async run() {
FILE: tests/integration/blacklist-blocking.test.js
function setTestURL (line 40) | function setTestURL(url) {
FILE: tests/integration/state-manager-integration.test.js
function setupPostMessageMock (line 28) | function setupPostMessageMock() {
FILE: tests/run-tests.js
constant JSDOM (line 17) | let JSDOM;
function runTests (line 121) | async function runTests() {
FILE: tests/unit/content/inject.test.js
function createVideoWithoutParentElement (line 61) | function createVideoWithoutParentElement() {
FILE: tests/unit/core/action-handler.test.js
function createTestVideoWithController (line 36) | function createTestVideoWithController(config, actionHandler, videoOptio...
FILE: tests/unit/core/icon-integration.test.js
function createMockVideo (line 32) | function createMockVideo(options = {}) {
FILE: tests/unit/observers/audio-size-handling.test.js
constant SMALL_AUDIO_SIZE (line 16) | const SMALL_AUDIO_SIZE = {
constant SMALL_VIDEO_SIZE (line 21) | const SMALL_VIDEO_SIZE = {
function createMockAudio (line 43) | function createMockAudio(options = {}) {
FILE: tests/unit/utils/recursive-shadow-dom.test.js
function createNestedShadowDOM (line 45) | function createNestedShadowDOM(depth, includeVideo = true) {
function createComplexPlayerStructure (line 80) | function createComplexPlayerStructure() {
Condensed preview — 82 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (491K chars).
[
{
"path": ".eslintrc.json",
"chars": 1052,
"preview": "{\n \"env\": {\n \"browser\": true,\n \"es2022\": true,\n \"node\": true,\n \"webextensions\": true\n },\n \"extends\": [\"es"
},
{
"path": ".gitignore",
"chars": 106,
"preview": ".DS_Store\n.cursor\n.claude\n.agent\n.idea\n.AGENT.md\nCLAUDE*.md\nlocal\ndist\ntests/e2e/screenshots\nnode_modules\n"
},
{
"path": ".pre-commit-config.yaml",
"chars": 455,
"preview": "# See https://pre-commit.com for more information\n# See https://pre-commit.com/hooks.html for more hooks\nrepos:\n - repo"
},
{
"path": "CONTRIBUTING.md",
"chars": 3127,
"preview": "# Contributing\n\nVideo Speed Controller is an open source project licensed under the MIT license\nwith many contributors. "
},
{
"path": "LICENSE",
"chars": 1070,
"preview": "MIT License\n\nCopyright (c) 2014 Ilya Grigorik\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
},
{
"path": "PRIVACY.md",
"chars": 1721,
"preview": "# Privacy Policy\nLast updated: 05/01/2024\n\n## Information Collection and Use\nWe do not collect any health, financial and"
},
{
"path": "README.md",
"chars": 5919,
"preview": "# The science of accelerated playback\n\n| Chrome Extension | Downlo"
},
{
"path": "manifest.json",
"chars": 1434,
"preview": "{\n \"name\": \"Video Speed Controller\",\n \"short_name\": \"videospeed\",\n \"version\": \"0.9.5\",\n \"manifest_version\": 3,\n \"mi"
},
{
"path": "package.json",
"chars": 2074,
"preview": "{\n \"name\": \"video-speed-controller\",\n \"version\": \"0.8.0\",\n \"description\": \"Speed up, slow down, advance and rewind HT"
},
{
"path": "scripts/build.mjs",
"chars": 2454,
"preview": "import esbuild from 'esbuild';\nimport process from 'process';\nimport path from 'path';\nimport { fileURLToPath } from 'ur"
},
{
"path": "src/background.js",
"chars": 2626,
"preview": "/**\n * Update extension icon based on enabled state\n * @param {boolean} enabled - Whether extension is enabled\n */\nasync"
},
{
"path": "src/content/inject.js",
"chars": 12188,
"preview": "/**\n * Video Speed Controller - Main Content Script\n * Modular architecture using global variables loaded via script arr"
},
{
"path": "src/content/injection-bridge.js",
"chars": 3223,
"preview": "/**\n * Content script injection helpers for bundled architecture\n * Handles script injection and message bridging betwee"
},
{
"path": "src/content/injector-simplified.js",
"chars": 3127,
"preview": "/**\n * Content script injection helpers for bundled architecture\n * Handles script injection and message bridging betwee"
},
{
"path": "src/core/action-handler.js",
"chars": 15909,
"preview": "/**\n * Action handling system for Video Speed Controller\n * \n */\n\nwindow.VSC = window.VSC || {};\n\nclass ActionHandler {\n"
},
{
"path": "src/core/settings.js",
"chars": 6024,
"preview": "/**\n * Settings management for Video Speed Controller\n */\n\nwindow.VSC = window.VSC || {};\n\nif (!window.VSC.VideoSpeedCon"
},
{
"path": "src/core/state-manager.js",
"chars": 3268,
"preview": "/**\n * Video Speed Controller State Manager \n * Tracks media elements for popup and keyboard commands.\n */\n\nwindow.VSC ="
},
{
"path": "src/core/storage-manager.js",
"chars": 7085,
"preview": "/**\n * Chrome storage management utilities\n * Handles storage access in both content script and page contexts\n */\n\nwindo"
},
{
"path": "src/core/video-controller.js",
"chars": 12250,
"preview": "/**\n * Video Controller class for managing individual video elements\n * \n */\n\nwindow.VSC = window.VSC || {};\n\nclass Vide"
},
{
"path": "src/entries/content-entry.js",
"chars": 1793,
"preview": "/**\n * Content script entry point - handles Chrome API access and page injection\n * This runs in the content script cont"
},
{
"path": "src/entries/inject-entry.js",
"chars": 1740,
"preview": "/**\n * Page context entry point - bundles all VSC modules for injection\n * This runs in the page context with access to "
},
{
"path": "src/observers/media-observer.js",
"chars": 8881,
"preview": "/**\n * Media element observer for finding and tracking video/audio elements\n */\n\nwindow.VSC = window.VSC || {};\n\nclass M"
},
{
"path": "src/observers/mutation-observer.js",
"chars": 8531,
"preview": "/**\n * DOM mutation observer for detecting video elements\n */\n\nwindow.VSC = window.VSC || {};\n\nclass VideoMutationObserv"
},
{
"path": "src/site-handlers/amazon-handler.js",
"chars": 1951,
"preview": "/**\n * Amazon Prime Video handler\n */\n\nwindow.VSC = window.VSC || {};\n\nclass AmazonHandler extends window.VSC.BaseSiteHa"
},
{
"path": "src/site-handlers/apple-handler.js",
"chars": 1561,
"preview": "/**\n * Apple TV+ handler\n */\n\nwindow.VSC = window.VSC || {};\n\nclass AppleHandler extends window.VSC.BaseSiteHandler {\n "
},
{
"path": "src/site-handlers/base-handler.js",
"chars": 2498,
"preview": "/**\n * Base class for site-specific handlers\n */\n\nwindow.VSC = window.VSC || {};\n\nclass BaseSiteHandler {\n constructor("
},
{
"path": "src/site-handlers/facebook-handler.js",
"chars": 3974,
"preview": "/**\n * Facebook-specific handler\n */\n\nwindow.VSC = window.VSC || {};\n\nclass FacebookHandler extends window.VSC.BaseSiteH"
},
{
"path": "src/site-handlers/index.js",
"chars": 3278,
"preview": "/**\n * Site handler factory and manager\n */\n\nwindow.VSC = window.VSC || {};\n\nclass SiteHandlerManager {\n constructor() "
},
{
"path": "src/site-handlers/netflix-handler.js",
"chars": 2631,
"preview": "/**\n * Netflix-specific handler\n */\n\nwindow.VSC = window.VSC || {};\n\nclass NetflixHandler extends window.VSC.BaseSiteHan"
},
{
"path": "src/site-handlers/scripts/netflix.js",
"chars": 517,
"preview": "window.addEventListener('message', function(event) {\n if (event.origin != 'https://www.netflix.com' || event.data.actio"
},
{
"path": "src/site-handlers/youtube-handler.js",
"chars": 3249,
"preview": "/**\n * YouTube-specific handler\n */\n\nwindow.VSC = window.VSC || {};\n\nclass YouTubeHandler extends window.VSC.BaseSiteHan"
},
{
"path": "src/styles/inject.css",
"chars": 2767,
"preview": "vsc-controller {\n /* Default visible state */\n visibility: visible;\n opacity: 1;\n display: block;\n width: auto !imp"
},
{
"path": "src/ui/controls.js",
"chars": 4126,
"preview": "/**\n * Control button interactions and event handling\n */\n\nwindow.VSC = window.VSC || {};\n\nclass ControlsManager {\n con"
},
{
"path": "src/ui/drag-handler.js",
"chars": 1834,
"preview": "/**\n * Drag functionality for video controller\n */\n\nwindow.VSC = window.VSC || {};\n\nclass DragHandler {\n /**\n * Handl"
},
{
"path": "src/ui/options/options.css",
"chars": 13962,
"preview": "/* Material Design Variables - matching popup.css */\n:root {\n /* Light theme */\n --md-surface: #ffffff;\n --md-surface"
},
{
"path": "src/ui/options/options.html",
"chars": 6144,
"preview": "<!DOCTYPE html>\n<html>\n\n<head>\n <title>Video Speed Controller: Options</title>\n <link rel=\"stylesheet\" href=\"options.c"
},
{
"path": "src/ui/options/options.js",
"chars": 20591,
"preview": "/**\n * Options page - depends on core VSC modules\n * Import required dependencies that are normally bundled in inject co"
},
{
"path": "src/ui/popup/popup.css",
"chars": 7186,
"preview": "/* Material Design Variables */\n:root {\n /* Light theme */\n --md-surface: #ffffff;\n --md-surface-variant: #f3f3f3;\n "
},
{
"path": "src/ui/popup/popup.html",
"chars": 3313,
"preview": "<!DOCTYPE html>\n<html>\n\n<head>\n <title>Video Speed Controller</title>\n <link rel=\"stylesheet\" href=\"popup.css\" />\n <s"
},
{
"path": "src/ui/popup/popup.js",
"chars": 5920,
"preview": "// Message type constants\nconst MessageTypes = {\n SET_SPEED: 'VSC_SET_SPEED',\n ADJUST_SPEED: 'VSC_ADJUST_SPEED',\n RES"
},
{
"path": "src/ui/shadow-dom.js",
"chars": 7323,
"preview": "/**\n * Shadow DOM creation and management\n */\n\nwindow.VSC = window.VSC || {};\n\nclass ShadowDOMManager {\n /**\n * Creat"
},
{
"path": "src/ui/vsc-controller-element.js",
"chars": 903,
"preview": "/**\n * Custom element for the video speed controller\n * Uses Web Components to avoid CSS conflicts with page styles\n */\n"
},
{
"path": "src/utils/blacklist.js",
"chars": 1529,
"preview": "/**\n * Blacklist checking utility\n * Works in both content script and test contexts\n */\n\n/**\n * Check if URL matches bla"
},
{
"path": "src/utils/constants.js",
"chars": 3256,
"preview": "/**\n * Constants and default values for Video Speed Controller\n */\n\nwindow.VSC = window.VSC || {};\nwindow.VSC.Constants "
},
{
"path": "src/utils/debug-helper.js",
"chars": 12554,
"preview": "/**\n * Debug helper for diagnosing Video Speed Controller issues\n * Add this to help troubleshoot controller visibility "
},
{
"path": "src/utils/dom-utils.js",
"chars": 5025,
"preview": "/**\n * DOM utility functions for Video Speed Controller\n */\n\nwindow.VSC = window.VSC || {};\nwindow.VSC.DomUtils = {};\n\n/"
},
{
"path": "src/utils/event-manager.js",
"chars": 9988,
"preview": "/**\n * Event management system for Video Speed Controller\n */\n\nwindow.VSC = window.VSC || {};\n\nclass EventManager {\n co"
},
{
"path": "src/utils/logger.js",
"chars": 4758,
"preview": "/**\n * Logging utility for Video Speed Controller\n */\n\nwindow.VSC = window.VSC || {};\n\nif (!window.VSC.logger) {\n class"
},
{
"path": "tests/e2e/basic.e2e.js",
"chars": 5606,
"preview": "/**\n * Basic E2E tests for Video Speed Controller extension\n */\n\nimport {\n launchChromeWithExtension,\n waitForExtensio"
},
{
"path": "tests/e2e/display-toggle.e2e.js",
"chars": 4564,
"preview": "/**\n * E2E test for display toggle functionality\n */\n\nimport { launchChromeWithExtension, sleep } from './e2e-utils.js';"
},
{
"path": "tests/e2e/e2e-utils.js",
"chars": 12285,
"preview": "/**\n * E2E test utilities for Chrome extension testing\n */\n\nimport puppeteer from 'puppeteer';\nimport { dirname, join } "
},
{
"path": "tests/e2e/icon.e2e.js",
"chars": 3033,
"preview": "#!/usr/bin/env node\n\n/**\n * Test the ultra-simplified architecture:\n * - Icon is always active (red) when extension is e"
},
{
"path": "tests/e2e/manual-test-guide.md",
"chars": 3824,
"preview": "# Manual E2E Testing Guide for Video Speed Controller\n\nSince automated E2E testing requires a GUI environment that may n"
},
{
"path": "tests/e2e/run-e2e.js",
"chars": 3180,
"preview": "#!/usr/bin/env node\n\n/**\n * E2E test runner for Video Speed Controller Chrome Extension\n * Usage: node tests/e2e/run-e2e"
},
{
"path": "tests/e2e/settings-injection.e2e.js",
"chars": 6495,
"preview": "/**\n * E2E tests for settings injection from content script to injected page context\n * Tests that user settings are pro"
},
{
"path": "tests/e2e/test-video.html",
"chars": 446,
"preview": "<!DOCTYPE html>\n<html>\n<head>\n <title>Video Speed Test</title>\n</head>\n<body>\n <h1>Video Speed Controller Test</h1>\n "
},
{
"path": "tests/e2e/validate-extension.js",
"chars": 5766,
"preview": "#!/usr/bin/env node\n\n/**\n * Extension validation script - checks extension files and structure\n * This runs without brow"
},
{
"path": "tests/e2e/youtube.e2e.js",
"chars": 8647,
"preview": "/**\n * YouTube E2E tests for Video Speed Controller extension\n */\n\nimport {\n launchChromeWithExtension,\n waitForExtens"
},
{
"path": "tests/fixtures/test-page.html",
"chars": 6345,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, in"
},
{
"path": "tests/helpers/chrome-mock.js",
"chars": 2839,
"preview": "/**\n * Chrome API mock for testing\n */\n\nconst mockStorage = {\n enabled: true,\n lastSpeed: 1.0,\n keyBindings: [],\n re"
},
{
"path": "tests/helpers/module-loader.js",
"chars": 2433,
"preview": "/**\n * Test module loader - loads all common dependencies for unit tests\n * This avoids the need for long import lists i"
},
{
"path": "tests/helpers/test-utils.js",
"chars": 6928,
"preview": "/**\n * Test utilities and helpers\n */\n\n/**\n * Create a mock video element for testing\n * @param {Object} options - Video"
},
{
"path": "tests/integration/blacklist-blocking.test.js",
"chars": 5106,
"preview": "/**\n * Integration tests for blacklist blocking behavior\n * Tests that controller does not load on blacklisted sites\n */"
},
{
"path": "tests/integration/module-integration.test.js",
"chars": 5266,
"preview": "/**\n * Integration tests for modular architecture\n * Using global variables to match browser extension architecture\n */\n"
},
{
"path": "tests/integration/state-manager-integration.test.js",
"chars": 8674,
"preview": "/**\n * Integration tests for VSCStateManager\n * Tests the complete flow: Controller creation → State tracking → Backgrou"
},
{
"path": "tests/integration/ui-to-storage-flow.test.js",
"chars": 9384,
"preview": "/**\n * Integration tests for full UI to storage flow\n * Tests the complete path from user interactions to storage persis"
},
{
"path": "tests/run-tests.js",
"chars": 7353,
"preview": "#!/usr/bin/env node\n\n/**\n * CLI test runner for Video Speed Controller\n * Usage: node tests/run-tests.js [unit|integrati"
},
{
"path": "tests/test-config.js",
"chars": 380,
"preview": "/**\n * Test configuration for Video Speed Controller\n */\n\nexport const testConfig = {\n timeout: 5000,\n retries: 2,\n s"
},
{
"path": "tests/unit/content/content-entry.test.js",
"chars": 2791,
"preview": "/**\n * Unit tests for content-entry.js behavior\n * Tests blacklist filtering and settings stripping\n */\n\nimport { Simple"
},
{
"path": "tests/unit/content/hydration-fix.test.js",
"chars": 4592,
"preview": "/**\n * Tests for hydration-safe initialization tracking\n * Ensures VSC doesn't modify DOM attributes that cause React hy"
},
{
"path": "tests/unit/content/inject.test.js",
"chars": 6388,
"preview": "/**\n * Unit tests for VideoSpeedExtension (inject.js)\n * Testing the fix for video elements without parentElement\n */\n\ni"
},
{
"path": "tests/unit/core/action-handler.test.js",
"chars": 31798,
"preview": "/**\n * Unit tests for ActionHandler class\n * Using global variables to match browser extension architecture\n */\n\nimport "
},
{
"path": "tests/unit/core/f-keys.test.js",
"chars": 6811,
"preview": "/**\n * Tests for F13-F24 and special key support\n * Verifies that the expanded keyboard handling works correctly\n */\n\nim"
},
{
"path": "tests/unit/core/icon-integration.test.js",
"chars": 7061,
"preview": "/**\n * Tests for icon integration (controller lifecycle events)\n */\n\nimport { installChromeMock, cleanupChromeMock } fro"
},
{
"path": "tests/unit/core/keyboard-shortcuts-saving.test.js",
"chars": 8393,
"preview": "/**\n * Tests for keyboard shortcuts saving fix\n * Verifies the resolution of the dual storage system issue\n */\n\nimport {"
},
{
"path": "tests/unit/core/settings.test.js",
"chars": 6732,
"preview": "/**\n * Unit tests for settings management\n * Using global variables to match browser extension architecture\n */\n\nimport "
},
{
"path": "tests/unit/core/video-controller.test.js",
"chars": 12053,
"preview": "/**\n * Unit tests for VideoController class\n * Using global variables to match browser extension architecture\n */\n\nimpor"
},
{
"path": "tests/unit/observers/audio-size-handling.test.js",
"chars": 10362,
"preview": "/**\n * Tests for audio element size handling\n */\n\nimport { installChromeMock, cleanupChromeMock } from '../../helpers/ch"
},
{
"path": "tests/unit/observers/mutation-observer.test.js",
"chars": 7319,
"preview": "// Import necessary modules\nimport { installChromeMock, cleanupChromeMock } from '../../helpers/chrome-mock.js';\nimport "
},
{
"path": "tests/unit/utils/blacklist-regex.test.js",
"chars": 5431,
"preview": "/**\n * Unit tests for blacklist regex parsing\n * Tests regex patterns with and without flags\n */\n\nimport { SimpleTestRun"
},
{
"path": "tests/unit/utils/event-manager.test.js",
"chars": 4371,
"preview": "/**\n * Unit tests for EventManager class\n * Tests cooldown behavior to prevent rapid changes\n */\n\nimport { installChrome"
},
{
"path": "tests/unit/utils/recursive-shadow-dom.test.js",
"chars": 10473,
"preview": "/**\n * Unit tests for recursive shadow DOM media element detection\n * Tests the findShadowMedia functionality in dom-uti"
}
]
About this extraction
This page contains the full source code of the igrigorik/videospeed GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 82 files (459.0 KB), approximately 113.4k tokens, and a symbol index with 294 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.