Compare commits

...

53 Commits

Author SHA1 Message Date
joshpatra ab7ae99807 v5.1.9.0-beta.1 2026-04-07 14:54:34 -04:00
joshpatra 5d47c511be Bump version to 5.1.9.0 2026-04-07 14:54:33 -04:00
joshpatra 7713ba5bad Fix Firefox extension link in README
Updated Firefox extension link in README.md.
2026-04-07 14:53:49 -04:00
joshpatra ce0b28de4f Update shortcut validation and stop tracking VS Code settings.
Allow empty keybinds for optional shortcuts while requiring Show/Hide controller, Decrease speed, and Increase speed, and remove tracked .vscode files while keeping the folder gitignored.
2026-04-07 14:52:50 -04:00
joshpatra a2b041e225 v5.1.8.0-beta.1 2026-04-07 14:33:07 -04:00
joshpatra 4f87aadfd7 Bump version to 5.1.8.0 2026-04-07 14:32:21 -04:00
joshpatra 8456111bf0 fix: correct AMO URL and lucide spec object key 2026-04-07 14:31:45 -04:00
joshpatra 6efe92a036 Refine site rule toggle and override UI 2026-04-07 14:31:41 -04:00
joshpatra 0cb13905ff Fix subtitle nudge site gating 2026-04-07 14:31:39 -04:00
joshpatra f32d1b3f71 Accept raw settings backups during import 2026-04-07 14:31:36 -04:00
joshpatra e6c56bcecb Allow zero controller opacity in settings 2026-04-07 14:31:34 -04:00
joshpatra a7a0aafd68 Add Vitest suite and fix wrapped local import restore 2026-04-07 14:31:27 -04:00
joshpatra 3cf1a4acd1 style(inject): normalize formatting 2026-04-04 16:14:56 -04:00
joshpatra a9956831c4 refactor(shortcuts): switch shortcut bindings to event.code 2026-04-04 13:33:11 -04:00
joshpatra 25d3acf576 v5.1.7.0-beta.1 2026-04-02 22:34:00 -04:00
joshpatra 7b8b4324af Bump version to 5.1.7.0 2026-04-02 22:33:59 -04:00
joshpatra 8b9e4bea1d fix: refresh site rules on DOM video changes 2026-04-02 21:16:37 -04:00
joshpatra 8c94cc2088 fix: clarify site rule auto-hide copy 2026-04-02 21:08:15 -04:00
joshpatra 19d3af02a2 refactor: store settings as sparse diffs 2026-04-02 21:07:31 -04:00
joshpatra 306e0e3ea0 Exclude Lucide cache from backups 2026-04-02 20:47:32 -04:00
joshpatra 1536c13c3e v5.1.6-beta.1 2026-04-02 18:20:49 -04:00
joshpatra 6bd319c8cc Bump version to 5.1.6 2026-04-02 18:20:48 -04:00
joshpatra 3aee8c8f9a fix: errors from web-ext 2026-04-02 18:20:33 -04:00
joshpatra 939ee08466 v5.1.4-beta.1 2026-04-02 14:17:18 -04:00
joshpatra 5a175c3cf8 Bump version to 5.1.4 2026-04-02 14:17:17 -04:00
joshpatra 805e5a82e5 fix: unicode reset glyph fallback in extension popup 2026-04-02 14:16:53 -04:00
joshpatra df34b1fee9 feat: Lucide subtitle nudge on/off targets and dual preview in options 2026-04-02 14:16:46 -04:00
joshpatra 0741c6e535 feat: custom Lucide icons for subtitle nudge on/off in inject 2026-04-02 14:16:40 -04:00
joshpatra fad0c49e65 v5.1.3-beta.1 2026-04-02 13:56:22 -04:00
joshpatra 66075fb6f3 Bump version to 5.1.3 2026-04-02 13:56:21 -04:00
joshpatra bf4025dcb4 fix: settings update 2026-04-02 13:54:01 -04:00
joshpatra 76a7b933bb v5.1.2-beta.1 2026-04-02 13:52:04 -04:00
joshpatra 1cd533fc5c Bump version to 5.1.2 2026-04-02 13:52:02 -04:00
joshpatra 8c5bd68d39 fix: popup control bar section layout in options 2026-04-02 13:44:03 -04:00
joshpatra 9c257af446 feat: omit settings from popup control bar 2026-04-02 13:43:56 -04:00
joshpatra 64a9b85587 fix: control bar icon clicks, hover/focus-within, nudge action 2026-04-02 13:43:43 -04:00
joshpatra edd997037a v5.1.1-beta.1 2026-04-02 13:11:47 -04:00
joshpatra f85a1f9f29 Bump version to 5.1.1 2026-04-02 13:11:46 -04:00
joshpatra 97366b76b6 chore: open options in tab 2026-04-02 13:09:09 -04:00
joshpatra 8269875bb1 fix: removed divider 2026-04-02 13:01:14 -04:00
joshpatra e34ec17f33 v5.1.0-beta.1 2026-04-02 12:53:10 -04:00
joshpatra 8d3905b654 Bump version to 5.1.0 2026-04-02 12:53:09 -04:00
joshpatra 7fd8a931d8 deploy: squash beta→main for stable; beta script pushes dev then pulls 2026-04-02 12:52:27 -04:00
joshpatra 17319c1e25 Re-run site rules on DOM media attach; extract refreshAllControllerGeometry 2026-04-02 12:52:27 -04:00
joshpatra 841c1a246e fix: nudge flash layout, Lucide icons, hover bar spacing 2026-04-02 12:52:27 -04:00
joshpatra ed0f63e8bc feat: user-customizable Lucide controller button icons 2026-04-02 12:52:27 -04:00
joshpatra 53f66f1eeb v5.0.4-beta.1 2026-04-01 16:31:49 -04:00
joshpatra f106ab490a Bump version to 5.0.4 2026-04-01 16:31:48 -04:00
joshpatra 5a38121e09 refactor: scripts update 2026-04-01 16:31:29 -04:00
joshpatra 36ed922b5c Add interactive deploy scripts for beta and AMO stable releases 2026-04-01 16:29:19 -04:00
joshpatra 3275d1f322 v5.0.2-beta.1 2026-04-01 16:24:24 -04:00
joshpatra f6d706f096 chore: version bump, deployment update 2026-04-01 16:21:44 -04:00
joshpatra 04292a8018 refactor: update settings, feat: change reset speed indicator to show speed it changes to/from 2026-04-01 16:18:36 -04:00
40 changed files with 8003 additions and 1150 deletions
+6 -2
View File
@@ -10,6 +10,8 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
WEB_EXT_IGNORE_FILES: scripts/**
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -46,7 +48,7 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: Beta ${{ github.ref_name }} name: ${{ github.ref_name }}
files: ${{ steps.xpi.outputs.file }} files: ${{ steps.xpi.outputs.file }}
prerelease: true prerelease: true
body: | body: |
@@ -61,7 +63,9 @@ jobs:
# Stable tag (v* without -beta) → Sign & submit to public AMO listing # Stable tag (v* without -beta) → Sign & submit to public AMO listing
- name: Sign & Submit to AMO (stable) - name: Sign & Submit to AMO (stable)
if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-beta') if:
startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name,
'-beta')
run: | run: |
web-ext sign \ web-ext sign \
--api-key ${{ secrets.FIREFOX_API_KEY }} \ --api-key ${{ secrets.FIREFOX_API_KEY }} \
-3
View File
@@ -1,3 +0,0 @@
{
"kiroAgent.configureMCP": "Disabled"
}
+3 -3
View File
@@ -1,5 +1,5 @@
# Available for Firefox # Available for Firefox
[![Add to Firefox](https://img.shields.io/badge/Add%20to-Firefox-orange?logo=firefox&logoColor=white)](https://addons.mozilla.org//firefox/addon/speeder/) [![Add to Firefox](https://img.shields.io/badge/Add%20to-Firefox-orange?logo=firefox&logoColor=white)](https://addons.mozilla.org/firefox/addon/speeder/)
# The science of accelerated playback # The science of accelerated playback
@@ -33,7 +33,7 @@ last point to listen to it a few more times.
![Player](https://cloud.githubusercontent.com/assets/2400185/24076745/5723e6ae-0c41-11e7-820c-1d8e814a2888.png) ![Player](https://cloud.githubusercontent.com/assets/2400185/24076745/5723e6ae-0c41-11e7-820c-1d8e814a2888.png)
#### *Install [Chrome](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk) or [Firefox](https://addons.mozilla.org/en-us/firefox/addon/videospeed/) Extension* #### *Install [Chrome](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk) or [Firefox](https://addons.mozilla.org/en-us/firefox/addon/speeder/) Extension*
\*\* Once the extension is installed simply navigate to any page that offers \*\* Once the extension is installed simply navigate to any page that offers
HTML5 video ([example](https://www.youtube.com/watch?v=E9FxNzv1Tr8)), and you'll HTML5 video ([example](https://www.youtube.com/watch?v=E9FxNzv1Tr8)), and you'll
@@ -56,7 +56,7 @@ shortcuts with different values, which will allow you to quickly toggle between
your most commonly used speeds. To add a new shortcut, open extension settings your most commonly used speeds. To add a new shortcut, open extension settings
and click "Add New". and click "Add New".
![settings Add New shortcut](https://user-images.githubusercontent.com/121805/50726471-50242200-1172-11e9-902f-0e5958387617.jpg) <img width="1760" height="1330" alt="image" src="https://github.com/user-attachments/assets/32e814dd-93ea-4943-8ec9-3eca735447ac" />
Some sites may assign other functionality to one of the assigned shortcut keys — Some sites may assign other functionality to one of the assigned shortcut keys —
these collisions are inevitable, unfortunately. As a workaround, the extension these collisions are inevitable, unfortunately. As a workaround, the extension
+16
View File
@@ -0,0 +1,16 @@
/* Runs via chrome.tabs.executeScript(allFrames) in the same isolated world as inject.js */
(function () {
try {
if (typeof getPrimaryVideoElement !== "function") {
return null;
}
var v = getPrimaryVideoElement();
if (!v) return null;
return {
speed: v.playbackRate,
preferred: !v.paused
};
} catch (e) {
return null;
}
})();
+82 -48
View File
@@ -1,37 +1,35 @@
// Import/Export functionality for Video Speed Controller settings // Import/Export functionality for Video Speed Controller settings
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var importExportUtils = speederShared.importExport || {};
function generateBackupFilename() { function generateBackupFilename() {
const now = new Date(); return importExportUtils.generateBackupFilename(new Date());
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
return `speeder-backup_${year}-${month}-${day}_${hours}.${minutes}.${seconds}.json`;
} }
function exportSettings() { function exportSettings() {
chrome.storage.sync.get(null, function (storage) { chrome.storage.sync.get(null, function (storage) {
const backup = { chrome.storage.local.get(null, function (localStorage) {
version: "1.0", const backup = importExportUtils.buildBackupPayload(
exportDate: new Date().toISOString(), storage,
settings: storage localStorage,
}; new Date()
);
const dataStr = JSON.stringify(backup, null, 2); const dataStr = JSON.stringify(backup, null, 2);
const blob = new Blob([dataStr], { type: "application/json" }); const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = generateBackupFilename(); link.download = generateBackupFilename();
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
showStatus("Settings exported successfully"); showStatus("Settings exported successfully");
});
}); });
} }
@@ -47,39 +45,75 @@ function importSettings() {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function (e) { reader.onload = function (e) {
try { try {
const backup = JSON.parse(e.target.result); const parsedBackup = importExportUtils.parseImportText(e.target.result);
let settingsToImport = null;
// Detect backup format: check for 'settings' wrapper or raw storage keys if (!parsedBackup) {
if (backup.settings && typeof backup.settings === "object") {
settingsToImport = backup.settings;
} else if (typeof backup === "object" && (backup.keyBindings || backup.rememberSpeed !== undefined)) {
settingsToImport = backup; // Raw storage object
}
if (!settingsToImport) {
showStatus("Error: Invalid backup file format", true); showStatus("Error: Invalid backup file format", true);
return; return;
} }
// Import all settings var settingsToImport = parsedBackup.settings;
chrome.storage.sync.clear(function () { var localToImport = parsedBackup.localSettings;
// If clear fails, we still try to set
chrome.storage.sync.set(settingsToImport, function () { function importLocalSettings(callback) {
if (parsedBackup.isWrappedBackup !== true) {
callback();
return;
}
chrome.storage.local.clear(function () {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
showStatus("Error: Failed to save imported settings - " + chrome.runtime.lastError.message, true); showStatus(
"Error: Failed to clear local extension data - " +
chrome.runtime.lastError.message,
true
);
return; return;
} }
showStatus("Settings imported successfully. Reloading...");
setTimeout(function () { if (localToImport && Object.keys(localToImport).length > 0) {
if (typeof restore_options === "function") { chrome.storage.local.set(localToImport, function () {
restore_options(); if (chrome.runtime.lastError) {
} else { showStatus(
location.reload(); "Error: Failed to save local extension data - " +
} chrome.runtime.lastError.message,
}, 500); true
);
return;
}
callback();
});
return;
}
callback();
}); });
}); }
function afterLocalImport() {
chrome.storage.sync.clear(function () {
chrome.storage.sync.set(settingsToImport, function () {
if (chrome.runtime.lastError) {
showStatus(
"Error: Failed to save imported settings - " +
chrome.runtime.lastError.message,
true
);
return;
}
showStatus("Settings imported successfully. Reloading...");
setTimeout(function () {
if (typeof restore_options === "function") {
restore_options();
} else {
location.reload();
}
}, 500);
});
});
}
importLocalSettings(afterLocalImport);
} catch (err) { } catch (err) {
showStatus("Error: Failed to parse backup file - " + err.message, true); showStatus("Error: Failed to parse backup file - " + err.message, true);
} }
+429 -360
View File
@@ -1,14 +1,25 @@
var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
var isUserSeek = false; // Track if seek was user-initiated var isUserSeek = false; // Track if seek was user-initiated
var lastToggleSpeed = {}; // Store last toggle speeds per video var lastToggleSpeed = {}; // Store last toggle speeds per video
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var controllerUtils = speederShared.controllerUtils || {};
var keyBindingUtils = speederShared.keyBindings || {};
var siteRuleUtils = speederShared.siteRules || {};
function getPrimaryVideoElement() {
if (!tc.mediaElements || tc.mediaElements.length === 0) return null;
for (var i = 0; i < tc.mediaElements.length; i++) {
var el = tc.mediaElements[i];
if (el && !el.paused) return el;
}
return tc.mediaElements[0];
}
var tc = { var tc = {
settings: { settings: {
lastSpeed: 1.0, lastSpeed: 1.0,
enabled: true, enabled: true,
speeds: {}, speeds: {},
displayKeyCode: 86,
rememberSpeed: false, rememberSpeed: false,
forceLastSavedSpeed: false, forceLastSavedSpeed: false,
audioBoolean: false, audioBoolean: false,
@@ -27,9 +38,10 @@ var tc = {
controllerButtons: ["rewind", "slower", "faster", "advance", "display"], controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
defaultLogLevel: 3, defaultLogLevel: 3,
logLevel: 3, logLevel: 3,
enableSubtitleNudge: true, // Enabled by default, but only activates on YouTube enableSubtitleNudge: false,
subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost
subtitleNudgeAmount: 0.001 subtitleNudgeAmount: 0.001,
customButtonIcons: {}
}, },
mediaElements: [], mediaElements: [],
isNudging: false, isNudging: false,
@@ -49,20 +61,23 @@ var vscObservedRoots = new WeakSet();
var requestIdle = var requestIdle =
typeof window.requestIdleCallback === "function" typeof window.requestIdleCallback === "function"
? window.requestIdleCallback.bind(window) ? window.requestIdleCallback.bind(window)
: function (callback, options) { : function(callback, options) {
return setTimeout(callback, (options && options.timeout) || 1); return setTimeout(callback, (options && options.timeout) || 1);
}; };
var controllerLocations = [ var controllerLocations = Array.isArray(controllerUtils.controllerLocations)
"top-left", ? controllerUtils.controllerLocations.slice()
"top-center", : [
"top-right", "top-left",
"middle-right", "top-center",
"bottom-right", "top-right",
"bottom-center", "middle-right",
"bottom-left", "bottom-right",
"middle-left" "bottom-center",
]; "bottom-left",
var defaultControllerLocation = controllerLocations[0]; "middle-left"
];
var defaultControllerLocation =
controllerUtils.defaultControllerLocation || controllerLocations[0];
var controllerLocationStyles = { var controllerLocationStyles = {
"top-left": { "top-left": {
top: "10px", top: "10px",
@@ -106,75 +121,26 @@ var controllerLocationStyles = {
} }
}; };
/* `label` fallback only when ui-icons has no path for the action. */
var controllerButtonDefs = { var controllerButtonDefs = {
rewind: { label: "\u00AB", className: "rw" }, rewind: { label: "", className: "rw" },
slower: { label: "\u2212", className: "" }, slower: { label: "", className: "" },
faster: { label: "+", className: "" }, faster: { label: "", className: "" },
advance: { label: "\u00BB", className: "rw" }, advance: { label: "", className: "rw" },
display: { label: "\u00D7", className: "hideButton" }, display: { label: "", className: "hideButton" },
reset: { label: "\u21BA", className: "" }, reset: { label: "\u21BB", className: "" },
fast: { label: "\u2605", className: "" }, fast: { label: "", className: "" },
settings: { label: "\u2699", className: "" }, settings: { label: "", className: "" },
pause: { label: "\u23EF", className: "" }, pause: { label: "", className: "" },
muted: { label: "M", className: "" }, muted: { label: "", className: "" },
mark: { label: "\u2691", className: "" }, mark: { label: "", className: "" },
jump: { label: "\u21E5", className: "" } jump: { label: "", className: "" }
}; };
var keyCodeToEventKey = { function createDefaultBinding(action, code, value) {
32: " ",
37: "ArrowLeft",
38: "ArrowUp",
39: "ArrowRight",
40: "ArrowDown",
96: "0",
97: "1",
98: "2",
99: "3",
100: "4",
101: "5",
102: "6",
103: "7",
104: "8",
105: "9",
106: "*",
107: "+",
109: "-",
110: ".",
111: "/",
112: "F1",
113: "F2",
114: "F3",
115: "F4",
116: "F5",
117: "F6",
118: "F7",
119: "F8",
120: "F9",
121: "F10",
122: "F11",
123: "F12",
186: ";",
188: "<",
189: "-",
187: "+",
190: ">",
191: "/",
192: "~",
219: "[",
220: "\\",
221: "]",
222: "'",
59: ";",
61: "+",
173: "-"
};
function createDefaultBinding(action, key, keyCode, value) {
return { return {
action: action, action: action,
key: key, code: code,
keyCode: keyCode,
value: value, value: value,
force: false, force: false,
predefined: true predefined: true
@@ -185,89 +151,73 @@ function defaultKeyBindings(storage) {
return [ return [
createDefaultBinding( createDefaultBinding(
"slower", "slower",
"S", "KeyS",
Number(storage.slowerKeyCode) || 83,
Number(storage.speedStep) || 0.1 Number(storage.speedStep) || 0.1
), ),
createDefaultBinding( createDefaultBinding(
"faster", "faster",
"D", "KeyD",
Number(storage.fasterKeyCode) || 68,
Number(storage.speedStep) || 0.1 Number(storage.speedStep) || 0.1
), ),
createDefaultBinding( createDefaultBinding(
"rewind", "rewind",
"Z", "KeyZ",
Number(storage.rewindKeyCode) || 90,
Number(storage.rewindTime) || 10 Number(storage.rewindTime) || 10
), ),
createDefaultBinding( createDefaultBinding(
"advance", "advance",
"X", "KeyX",
Number(storage.advanceKeyCode) || 88,
Number(storage.advanceTime) || 10 Number(storage.advanceTime) || 10
), ),
createDefaultBinding( createDefaultBinding(
"reset", "reset",
"R", "KeyR",
Number(storage.resetKeyCode) || 82,
1.0 1.0
), ),
createDefaultBinding( createDefaultBinding(
"fast", "fast",
"G", "KeyG",
Number(storage.fastKeyCode) || 71,
Number(storage.fastSpeed) || 1.8 Number(storage.fastSpeed) || 1.8
), ),
createDefaultBinding( createDefaultBinding(
"move", "move",
"P", "KeyP",
80,
0 0
), ),
createDefaultBinding( createDefaultBinding(
"toggleSubtitleNudge", "toggleSubtitleNudge",
"N", "KeyN",
78,
0 0
) )
]; ];
} }
function ensureDefaultKeyBinding(action, key, keyCode, value) { function ensureDefaultKeyBinding(action, code, value) {
if (tc.settings.keyBindings.some((binding) => binding.action === action)) { if (tc.settings.keyBindings.some((binding) => binding.action === action)) {
return false; return false;
} }
tc.settings.keyBindings.push( tc.settings.keyBindings.push(
createDefaultBinding(action, key, keyCode, value) createDefaultBinding(action, code, value)
); );
return true; return true;
} }
function getLegacyKeyCode(binding) { function getLegacyKeyCode(binding) {
if (!binding) return null; return keyBindingUtils.getLegacyKeyCode(binding);
if (Number.isInteger(binding.keyCode)) return binding.keyCode;
if (typeof binding.key === "number" && Number.isInteger(binding.key)) {
return binding.key;
}
return null;
} }
function normalizeControllerLocation(location) { function normalizeControllerLocation(location) {
if (controllerLocations.includes(location)) return location; return controllerUtils.normalizeControllerLocation(
return defaultControllerLocation; location,
defaultControllerLocation
);
} }
var CONTROLLER_MARGIN_MAX_PX = 200; var CONTROLLER_MARGIN_MAX_PX = 200;
function normalizeControllerMarginPx(value, fallback) { function normalizeControllerMarginPx(value, fallback) {
var n = Number(value); return controllerUtils.clampControllerMarginPx(value, fallback);
if (!Number.isFinite(n)) return fallback;
return Math.min(
CONTROLLER_MARGIN_MAX_PX,
Math.max(0, Math.round(n))
);
} }
function applyControllerMargins(controller) { function applyControllerMargins(controller) {
@@ -306,9 +256,7 @@ function applyControllerMargins(controller) {
} }
function getNextControllerLocation(location) { function getNextControllerLocation(location) {
var normalizedLocation = normalizeControllerLocation(location); return controllerUtils.getNextControllerLocation(location);
var currentIndex = controllerLocations.indexOf(normalizedLocation);
return controllerLocations[(currentIndex + 1) % controllerLocations.length];
} }
function getControllerElement(videoOrController) { function getControllerElement(videoOrController) {
@@ -438,7 +386,7 @@ function captureSiteRuleBase() {
? tc.settings.controllerButtons.slice() ? tc.settings.controllerButtons.slice()
: tc.settings.controllerButtons, : tc.settings.controllerButtons,
keyBindings: Array.isArray(tc.settings.keyBindings) keyBindings: Array.isArray(tc.settings.keyBindings)
? tc.settings.keyBindings.map(function (binding) { ? tc.settings.keyBindings.map(function(binding) {
return Object.assign({}, binding); return Object.assign({}, binding);
}) })
: tc.settings.keyBindings : tc.settings.keyBindings
@@ -464,7 +412,7 @@ function resetSettingsFromSiteRuleBase() {
? base.controllerButtons.slice() ? base.controllerButtons.slice()
: base.controllerButtons; : base.controllerButtons;
tc.settings.keyBindings = Array.isArray(base.keyBindings) tc.settings.keyBindings = Array.isArray(base.keyBindings)
? base.keyBindings.map(function (binding) { ? base.keyBindings.map(function(binding) {
return Object.assign({}, binding); return Object.assign({}, binding);
}) })
: base.keyBindings; : base.keyBindings;
@@ -528,44 +476,41 @@ function cycleControllerLocation(video) {
} }
function normalizeBindingKey(key) { function normalizeBindingKey(key) {
if (typeof key !== "string" || key.length === 0) return null; return keyBindingUtils.normalizeBindingKey(key);
if (key === "Spacebar") return " ";
if (key === "Esc") return "Escape";
if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase();
return key;
} }
function legacyKeyCodeToBinding(keyCode) { function legacyBindingKeyToCode(key) {
if (!Number.isInteger(keyCode)) return null; return keyBindingUtils.legacyBindingKeyToCode(key);
var key = keyCodeToEventKey[keyCode];
if (!key && keyCode >= 48 && keyCode <= 57) {
key = String.fromCharCode(keyCode);
}
if (!key && keyCode >= 65 && keyCode <= 90) {
key = String.fromCharCode(keyCode);
}
return {
key: normalizeBindingKey(key),
keyCode: keyCode,
code: null,
disabled: false
};
} }
function normalizeStoredBinding(binding, fallbackKeyCode) { function legacyKeyCodeToCode(keyCode) {
var fallbackBinding = legacyKeyCodeToBinding(fallbackKeyCode); return keyBindingUtils.legacyKeyCodeToCode(keyCode);
if (!binding) return fallbackBinding; }
function inferBindingCode(binding, fallbackCode) {
return keyBindingUtils.inferBindingCode(binding, fallbackCode);
}
function normalizeStoredBinding(binding, fallbackCode) {
if (!binding) {
if (!fallbackCode) return null;
return {
code: fallbackCode,
disabled: false,
value: 0,
force: "false",
predefined: false
};
}
if ( if (
binding.disabled === true || binding.disabled === true ||
(binding.key === null && (binding.code === null &&
binding.keyCode === null && binding.key === null &&
binding.code === null) binding.keyCode === null)
) { ) {
return { return {
action: binding.action, action: binding.action,
key: null,
keyCode: null,
code: null, code: null,
disabled: true, disabled: true,
value: Number(binding.value), value: Number(binding.value),
@@ -574,46 +519,20 @@ function normalizeStoredBinding(binding, fallbackKeyCode) {
}; };
} }
var normalizedCode = inferBindingCode(binding, fallbackCode);
if (!normalizedCode) {
return null;
}
var normalized = { var normalized = {
action: binding.action, action: binding.action,
key: null, code: normalizedCode,
keyCode: null,
code:
typeof binding.code === "string" && binding.code.length > 0
? binding.code
: null,
disabled: false, disabled: false,
value: Number(binding.value), value: Number(binding.value),
force: String(binding.force) === "true" ? "true" : "false", force: String(binding.force) === "true" ? "true" : "false",
predefined: Boolean(binding.predefined) predefined: Boolean(binding.predefined)
}; };
if (typeof binding.key === "string") {
normalized.key = normalizeBindingKey(binding.key);
}
var legacyKeyCode = getLegacyKeyCode(binding);
if (Number.isInteger(legacyKeyCode)) {
var legacyBinding = legacyKeyCodeToBinding(legacyKeyCode);
if (legacyBinding) {
normalized.key = normalized.key || legacyBinding.key;
normalized.keyCode = legacyKeyCode;
}
}
if (Number.isInteger(binding.keyCode)) {
normalized.keyCode = binding.keyCode;
}
if (!normalized.key && fallbackBinding) {
normalized.key = fallbackBinding.key;
if (normalized.keyCode === null) normalized.keyCode = fallbackBinding.keyCode;
}
if (!normalized.key && !normalized.code && normalized.keyCode === null) {
return null;
}
return normalized; return normalized;
} }
@@ -730,19 +649,32 @@ function isSubtitleNudgeSupported(video) {
return Boolean(video); return Boolean(video);
} }
function isSubtitleNudgeAvailableForVideo(video) {
return isSubtitleNudgeSupported(video) && Boolean(tc.settings.enableSubtitleNudge);
}
function isSubtitleNudgeEnabledForVideo(video) { function isSubtitleNudgeEnabledForVideo(video) {
if (!video || !video.vsc) return tc.settings.enableSubtitleNudge; if (!isSubtitleNudgeAvailableForVideo(video)) return false;
if (!video || !video.vsc) return true;
if (typeof video.vsc.subtitleNudgeEnabledOverride === "boolean") { if (typeof video.vsc.subtitleNudgeEnabledOverride === "boolean") {
return video.vsc.subtitleNudgeEnabledOverride; return video.vsc.subtitleNudgeEnabledOverride;
} }
return tc.settings.enableSubtitleNudge; return true;
} }
function setSubtitleNudgeEnabledForVideo(video, enabled) { function setSubtitleNudgeEnabledForVideo(video, enabled) {
if (!video || !video.vsc) return false; if (!video || !video.vsc) return false;
if (!isSubtitleNudgeAvailableForVideo(video)) {
video.vsc.subtitleNudgeEnabledOverride = null;
video.vsc.stopSubtitleNudge();
updateSubtitleNudgeIndicator(video);
return false;
}
var normalizedEnabled = Boolean(enabled); var normalizedEnabled = Boolean(enabled);
video.vsc.subtitleNudgeEnabledOverride = normalizedEnabled; video.vsc.subtitleNudgeEnabledOverride = normalizedEnabled;
@@ -759,7 +691,7 @@ function setSubtitleNudgeEnabledForVideo(video, enabled) {
if (flashEl) { if (flashEl) {
flashEl.classList.add("visible"); flashEl.classList.add("visible");
clearTimeout(flashEl._flashTimer); clearTimeout(flashEl._flashTimer);
flashEl._flashTimer = setTimeout(function () { flashEl._flashTimer = setTimeout(function() {
flashEl.classList.remove("visible"); flashEl.classList.remove("visible");
}, 1500); }, 1500);
} }
@@ -767,27 +699,68 @@ function setSubtitleNudgeEnabledForVideo(video, enabled) {
return normalizedEnabled; return normalizedEnabled;
} }
function renderSubtitleNudgeIndicatorContent(target, isEnabled) {
if (!target) return;
var action = isEnabled ? "subtitleNudgeOn" : "subtitleNudgeOff";
var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg;
vscClearElement(target);
if (custom) {
var customWrap = vscCreateSvgWrap(
target.ownerDocument || document,
custom,
"vsc-btn-icon"
);
if (customWrap) {
target.appendChild(customWrap);
return;
}
}
if (typeof vscIconSvgString !== "function") {
target.textContent = isEnabled ? "✓" : "×";
return;
}
var svg = vscIconSvgString(action, 14);
if (!svg) {
target.textContent = isEnabled ? "✓" : "×";
return;
}
var wrap = vscCreateSvgWrap(target.ownerDocument || document, svg, "vsc-btn-icon");
if (wrap) {
target.appendChild(wrap);
return;
}
target.textContent = isEnabled ? "✓" : "×";
}
function updateSubtitleNudgeIndicator(video) { function updateSubtitleNudgeIndicator(video) {
if (!video || !video.vsc) return; if (!video || !video.vsc) return;
var isAvailable = isSubtitleNudgeAvailableForVideo(video);
var isEnabled = isSubtitleNudgeEnabledForVideo(video); var isEnabled = isSubtitleNudgeEnabledForVideo(video);
var label = isEnabled ? "✓" : "×"; var title = !isAvailable
var title = isEnabled ? "Subtitle nudge enabled" : "Subtitle nudge disabled"; ? "Subtitle nudge unavailable on this site"
: isEnabled
? "Subtitle nudge enabled"
: "Subtitle nudge disabled";
var indicator = video.vsc.subtitleNudgeIndicator; var indicator = video.vsc.subtitleNudgeIndicator;
if (indicator) { if (indicator) {
indicator.textContent = label; renderSubtitleNudgeIndicatorContent(indicator, isEnabled);
indicator.dataset.enabled = isEnabled ? "true" : "false"; indicator.dataset.enabled = isEnabled ? "true" : "false";
indicator.dataset.supported = "true"; indicator.dataset.supported = isAvailable ? "true" : "false";
indicator.title = title; indicator.title = title;
indicator.setAttribute("aria-label", title); indicator.setAttribute("aria-label", title);
} }
var flashEl = video.vsc.nudgeFlashIndicator; var flashEl = video.vsc.nudgeFlashIndicator;
if (flashEl) { if (flashEl) {
flashEl.textContent = label; renderSubtitleNudgeIndicatorContent(flashEl, isEnabled);
flashEl.dataset.enabled = isEnabled ? "true" : "false"; flashEl.dataset.enabled = isEnabled ? "true" : "false";
flashEl.dataset.supported = "true"; flashEl.dataset.supported = isAvailable ? "true" : "false";
flashEl.setAttribute("aria-label", title);
} }
} }
@@ -797,7 +770,7 @@ function schedulePersistLastSpeed(speed) {
tc.pendingLastSpeedValue = speed; tc.pendingLastSpeedValue = speed;
if (tc.pendingLastSpeedSave !== null) return; if (tc.pendingLastSpeedSave !== null) return;
tc.pendingLastSpeedSave = setTimeout(function () { tc.pendingLastSpeedSave = setTimeout(function() {
var speedToPersist = tc.pendingLastSpeedValue; var speedToPersist = tc.pendingLastSpeedValue;
tc.pendingLastSpeedSave = null; tc.pendingLastSpeedSave = null;
@@ -805,7 +778,8 @@ function schedulePersistLastSpeed(speed) {
return; return;
} }
chrome.storage.sync.set({ lastSpeed: speedToPersist }, function () { }); chrome.storage.sync.set({ lastSpeed: speedToPersist }, function() {
});
tc.persistedLastSpeed = speedToPersist; tc.persistedLastSpeed = speedToPersist;
}, 250); }, 250);
} }
@@ -864,6 +838,10 @@ function applySourceTransitionPolicy(video, forceUpdate) {
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) { if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
setSpeed(video, desiredSpeed, false, false); setSpeed(video, desiredSpeed, false, false);
} }
// Same-tab SPA (e.g. YouTube watch → Shorts): URL can change while remember-speed
// already ran on src mutation — re-apply margins / location / opacity for new rules.
reapplySiteRulesAndControllerGeometry();
} }
function extendSpeedRestoreWindow(video, duration) { function extendSpeedRestoreWindow(video, duration) {
@@ -883,7 +861,7 @@ function scheduleSpeedRestore(video, desiredSpeed, reason) {
clearTimeout(video.vsc.restoreSpeedTimer); clearTimeout(video.vsc.restoreSpeedTimer);
} }
video.vsc.restoreSpeedTimer = setTimeout(function () { video.vsc.restoreSpeedTimer = setTimeout(function() {
if (!video.vsc) return; if (!video.vsc) return;
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) { if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
@@ -930,19 +908,13 @@ function takePendingRateChange(video, currentSpeed) {
} }
function matchesKeyBinding(binding, event) { function matchesKeyBinding(binding, event) {
if (!binding || binding.disabled) return false; return Boolean(
binding &&
var normalizedEventKey = normalizeBindingKey(event.key); binding.disabled !== true &&
if (binding.key && normalizedEventKey) { typeof binding.code === "string" &&
return binding.key === normalizedEventKey; binding.code.length > 0 &&
} binding.code === event.code
);
if (binding.code && event.code) {
return binding.code === event.code;
}
var legacyKeyCode = getLegacyKeyCode(binding);
return Number.isInteger(legacyKeyCode) && legacyKeyCode === event.keyCode;
} }
function mediaSelector() { function mediaSelector() {
@@ -972,7 +944,7 @@ function hasUsableMediaSource(node) {
} }
if (node.querySelectorAll) { if (node.querySelectorAll) {
return Array.from(node.querySelectorAll("source[src]")).some(function ( return Array.from(node.querySelectorAll("source[src]")).some(function(
source source
) { ) {
var src = source.getAttribute("src"); var src = source.getAttribute("src");
@@ -992,6 +964,14 @@ function ensureController(node, parent) {
); );
return null; return null;
} }
// href selects site rules; re-run on every new/usable media so margins/opacity match current URL.
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) {
return null;
}
refreshAllControllerGeometry();
log( log(
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`, `Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
4 4
@@ -1103,7 +1083,7 @@ function patchAttachShadow() {
} }
var originalAttachShadow = Element.prototype.attachShadow; var originalAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function () { Element.prototype.attachShadow = function() {
var shadowRoot = originalAttachShadow.apply(this, arguments); var shadowRoot = originalAttachShadow.apply(this, arguments);
try { try {
if (shadowRoot) { if (shadowRoot) {
@@ -1134,7 +1114,7 @@ function log(message, level) {
} }
} }
chrome.storage.sync.get(tc.settings, function (storage) { chrome.storage.sync.get(tc.settings, function(storage) {
var storedBindings = Array.isArray(storage.keyBindings) var storedBindings = Array.isArray(storage.keyBindings)
? storage.keyBindings ? storage.keyBindings
: []; : [];
@@ -1149,7 +1129,6 @@ chrome.storage.sync.get(tc.settings, function (storage) {
chrome.storage.sync.set({ chrome.storage.sync.set({
keyBindings: tc.settings.keyBindings, keyBindings: tc.settings.keyBindings,
version: tc.settings.version, version: tc.settings.version,
displayKeyCode: tc.settings.displayKeyCode,
rememberSpeed: tc.settings.rememberSpeed, rememberSpeed: tc.settings.rememberSpeed,
forceLastSavedSpeed: tc.settings.forceLastSavedSpeed, forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
audioBoolean: tc.settings.audioBoolean, audioBoolean: tc.settings.audioBoolean,
@@ -1169,7 +1148,6 @@ chrome.storage.sync.get(tc.settings, function (storage) {
tc.settings.lastSpeed = 1.0; tc.settings.lastSpeed = 1.0;
} }
tc.persistedLastSpeed = tc.settings.lastSpeed; tc.persistedLastSpeed = tc.settings.lastSpeed;
tc.settings.displayKeyCode = Number(storage.displayKeyCode);
tc.settings.rememberSpeed = Boolean(storage.rememberSpeed); tc.settings.rememberSpeed = Boolean(storage.rememberSpeed);
tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed); tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed);
tc.settings.audioBoolean = Boolean(storage.audioBoolean); tc.settings.audioBoolean = Boolean(storage.audioBoolean);
@@ -1210,25 +1188,6 @@ chrome.storage.sync.get(tc.settings, function (storage) {
? storage.controllerButtons ? storage.controllerButtons
: tc.settings.controllerButtons; : tc.settings.controllerButtons;
// Migrate legacy blacklist if present
if (storage.blacklist && typeof storage.blacklist === "string" && tc.settings.siteRules.length === 0) {
var lines = storage.blacklist.split("\n");
lines.forEach((line) => {
var pattern = line.replace(regStrip, "");
if (pattern.length > 0) {
tc.settings.siteRules.push({
pattern: pattern,
disableExtension: true
});
}
});
if (tc.settings.siteRules.length > 0) {
chrome.storage.sync.set({ siteRules: tc.settings.siteRules });
chrome.storage.sync.remove(["blacklist"]);
log("Migrated legacy blacklist to site rules", 4);
}
}
tc.settings.enableSubtitleNudge = tc.settings.enableSubtitleNudge =
typeof storage.enableSubtitleNudge !== "undefined" typeof storage.enableSubtitleNudge !== "undefined"
? Boolean(storage.enableSubtitleNudge) ? Boolean(storage.enableSubtitleNudge)
@@ -1243,14 +1202,13 @@ chrome.storage.sync.get(tc.settings, function (storage) {
addedDefaultBinding = addedDefaultBinding =
ensureDefaultKeyBinding( ensureDefaultKeyBinding(
"display", "display",
"V", "KeyV",
Number(storage.displayKeyCode) || 86,
0 0
) || addedDefaultBinding; ) || addedDefaultBinding;
addedDefaultBinding = addedDefaultBinding =
ensureDefaultKeyBinding("move", "P", 80, 0) || addedDefaultBinding; ensureDefaultKeyBinding("move", "KeyP", 0) || addedDefaultBinding;
addedDefaultBinding = addedDefaultBinding =
ensureDefaultKeyBinding("toggleSubtitleNudge", "N", 78, 0) || ensureDefaultKeyBinding("toggleSubtitleNudge", "KeyN", 0) ||
addedDefaultBinding; addedDefaultBinding;
if (addedDefaultBinding) { if (addedDefaultBinding) {
@@ -1262,48 +1220,104 @@ chrome.storage.sync.get(tc.settings, function (storage) {
// We use a global flag to ensure the listener is only attached once. // We use a global flag to ensure the listener is only attached once.
if (!window.vscMessageListener) { if (!window.vscMessageListener) {
chrome.runtime.onMessage.addListener( chrome.runtime.onMessage.addListener(
function (request, sender, sendResponse) { function(request, sender, sendResponse) {
if (request.action === "rescan_page") { if (request.action === "rescan_page") {
log("Re-scan command received from popup.", 4); log("Re-scan command received from popup.", 4);
initializeWhenReady(document, true); initializeWhenReady(document, true);
sendResponse({ status: "complete" }); sendResponse({ status: "complete" });
} else if (request.action === "get_speed") { return false;
var speed = 1.0; }
if (tc.mediaElements && tc.mediaElements.length > 0) { if (request.action === "get_speed") {
for (var i = 0; i < tc.mediaElements.length; i++) { // Do not sendResponse in frames with no media — only one response is
if (tc.mediaElements[i] && !tc.mediaElements[i].paused) { // accepted tab-wide, and the top frame often wins before an iframe.
speed = tc.mediaElements[i].playbackRate; var videoGs = getPrimaryVideoElement();
break; if (!videoGs) return false;
} sendResponse({
} speed: videoGs.playbackRate
if (speed === 1.0 && tc.mediaElements[0]) { });
speed = tc.mediaElements[0].playbackRate; return false;
} }
} if (request.action === "get_page_context") {
sendResponse({ speed: speed });
} else if (request.action === "get_page_context") {
sendResponse({ url: location.href }); sendResponse({ url: location.href });
} else if (request.action === "run_action") { return false;
}
if (request.action === "run_action") {
var value = request.value; var value = request.value;
if (value === undefined || value === null) { if (value === undefined || value === null) {
value = getKeyBindings(request.actionName, "value"); value = getKeyBindings(request.actionName, "value");
} }
runAction(request.actionName, value); runAction(request.actionName, value);
var newSpeed = 1.0; var videoAfter = getPrimaryVideoElement();
if (tc.mediaElements && tc.mediaElements.length > 0) { if (!videoAfter) return false;
newSpeed = tc.mediaElements[0].playbackRate; sendResponse({
} speed: videoAfter.playbackRate
sendResponse({ speed: newSpeed }); });
return false;
} }
return false;
return true;
} }
); );
// Set the flag to prevent adding the listener again. // Set the flag to prevent adding the listener again.
window.vscMessageListener = true; window.vscMessageListener = true;
} }
initializeWhenReady(document); chrome.storage.local.get(["customButtonIcons"], function(loc) {
tc.settings.customButtonIcons =
loc &&
loc.customButtonIcons &&
typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
if (!window.vscCustomIconListener) {
window.vscCustomIconListener = true;
chrome.storage.onChanged.addListener(function(changes, area) {
if (area !== "local" || !changes.customButtonIcons) return;
var nv = changes.customButtonIcons.newValue;
tc.settings.customButtonIcons =
nv && typeof nv === "object" ? nv : {};
if (tc.mediaElements && tc.mediaElements.length) {
tc.mediaElements.forEach(function(video) {
if (!video.vsc || !video.vsc.div) return;
var doc = video.ownerDocument;
var shadow = video.vsc.div.shadowRoot;
if (!shadow) return;
shadow.querySelectorAll("button[data-action]").forEach(function(btn) {
var act = btn.dataset.action;
if (!act) return;
var svg =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[act] &&
tc.settings.customButtonIcons[act].svg;
vscClearElement(btn);
if (svg) {
var cw = vscCreateSvgWrap(doc, svg, "vsc-btn-icon");
if (cw) {
btn.appendChild(cw);
} else {
var cdf = controllerButtonDefs[act];
btn.textContent = (cdf && cdf.label) || "?";
}
} else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, act, 14);
if (wrap) {
btn.appendChild(wrap);
} else {
var cdf = controllerButtonDefs[act];
btn.textContent = (cdf && cdf.label) || "?";
}
} else {
var cdf2 = controllerButtonDefs[act];
btn.textContent = (cdf2 && cdf2.label) || "?";
}
});
updateSubtitleNudgeIndicator(video);
});
}
});
}
initializeWhenReady(document);
});
}); });
function getKeyBindings(action, what = "value") { function getKeyBindings(action, what = "value") {
@@ -1313,6 +1327,7 @@ function getKeyBindings(action, what = "value") {
return false; return false;
} }
} }
function setKeyBindings(action, value) { function setKeyBindings(action, value) {
tc.settings.keyBindings.find((item) => item.action === action)["value"] = tc.settings.keyBindings.find((item) => item.action === action)["value"] =
value; value;
@@ -1321,7 +1336,27 @@ function setKeyBindings(action, value) {
function createControllerButton(doc, action, label, className) { function createControllerButton(doc, action, label, className) {
var button = doc.createElement("button"); var button = doc.createElement("button");
button.dataset.action = action; button.dataset.action = action;
button.textContent = label; var custom =
tc.settings.customButtonIcons &&
tc.settings.customButtonIcons[action] &&
tc.settings.customButtonIcons[action].svg;
if (custom) {
var customWrap = vscCreateSvgWrap(doc, custom, "vsc-btn-icon");
if (customWrap) {
button.appendChild(customWrap);
} else {
button.textContent = label || "?";
}
} else if (typeof vscIconWrap === "function") {
var wrap = vscIconWrap(doc, action, 14);
if (wrap) {
button.appendChild(wrap);
} else {
button.textContent = label || "?";
}
} else {
button.textContent = label || "?";
}
if (className) { if (className) {
button.className = className; button.className = className;
} }
@@ -1329,7 +1364,7 @@ function createControllerButton(doc, action, label, className) {
} }
function defineVideoController() { function defineVideoController() {
tc.videoController = function (target, parent) { tc.videoController = function(target, parent) {
if (target.vsc) return target.vsc; if (target.vsc) return target.vsc;
tc.mediaElements.push(target); tc.mediaElements.push(target);
target.vsc = this; target.vsc = this;
@@ -1343,11 +1378,13 @@ function defineVideoController() {
this.suppressedRateChangeCount = 0; this.suppressedRateChangeCount = 0;
this.suppressedRateChangeUntil = 0; this.suppressedRateChangeUntil = 0;
this.visibilityResumeHandler = null; this.visibilityResumeHandler = null;
this.resetToggleArmed = false;
this.resetButtonEl = null;
this.controllerLocation = normalizeControllerLocation( this.controllerLocation = normalizeControllerLocation(
tc.settings.controllerLocation tc.settings.controllerLocation
); );
log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || 'none'}`, 4); log(`Creating video controller for ${target.tagName} with src: ${target.src || target.currentSrc || "none"}`, 4);
let storedSpeed = sanitizeSpeed(resolveTargetSpeed(target), 1.0); let storedSpeed = sanitizeSpeed(resolveTargetSpeed(target), 1.0);
this.targetSpeed = storedSpeed; this.targetSpeed = storedSpeed;
@@ -1367,9 +1404,9 @@ function defineVideoController() {
return; return;
} }
log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains('vsc-hidden')}`, 4); log(`Controller created and attached to DOM. Hidden: ${this.div.classList.contains("vsc-hidden")}`, 4);
var mediaEventAction = function (event) { var mediaEventAction = function(event) {
if ( if (
event.type === "loadedmetadata" || event.type === "loadedmetadata" ||
event.type === "loadeddata" || event.type === "loadeddata" ||
@@ -1487,7 +1524,7 @@ function defineVideoController() {
this.startSubtitleNudge(); this.startSubtitleNudge();
}; };
tc.videoController.prototype.remove = function () { tc.videoController.prototype.remove = function() {
this.stopSubtitleNudge(); this.stopSubtitleNudge();
if (this.youTubeAutoHideObserver) { if (this.youTubeAutoHideObserver) {
this.youTubeAutoHideObserver.disconnect(); this.youTubeAutoHideObserver.disconnect();
@@ -1519,7 +1556,7 @@ function defineVideoController() {
if (idx != -1) tc.mediaElements.splice(idx, 1); if (idx != -1) tc.mediaElements.splice(idx, 1);
}; };
tc.videoController.prototype.startSubtitleNudge = function () { tc.videoController.prototype.startSubtitleNudge = function() {
if ( if (
!isSubtitleNudgeSupported(this.video) || !isSubtitleNudgeSupported(this.video) ||
!isSubtitleNudgeEnabledForVideo(this.video) || !isSubtitleNudgeEnabledForVideo(this.video) ||
@@ -1592,7 +1629,7 @@ function defineVideoController() {
log(`Nudge: Starting with interval ${tc.settings.subtitleNudgeInterval}ms.`, 5); log(`Nudge: Starting with interval ${tc.settings.subtitleNudgeInterval}ms.`, 5);
}; };
tc.videoController.prototype.stopSubtitleNudge = function () { tc.videoController.prototype.stopSubtitleNudge = function() {
if (this.nudgeAnimationId !== null) { if (this.nudgeAnimationId !== null) {
clearTimeout(this.nudgeAnimationId); clearTimeout(this.nudgeAnimationId);
this.nudgeAnimationId = null; this.nudgeAnimationId = null;
@@ -1610,7 +1647,7 @@ function defineVideoController() {
// doesn't lose the user's intended speed if the site hijacks it. // doesn't lose the user's intended speed if the site hijacks it.
}; };
tc.videoController.prototype.performImmediateNudge = function () { tc.videoController.prototype.performImmediateNudge = function() {
if ( if (
!isSubtitleNudgeSupported(this.video) || !isSubtitleNudgeSupported(this.video) ||
!isSubtitleNudgeEnabledForVideo(this.video) || !isSubtitleNudgeEnabledForVideo(this.video) ||
@@ -1640,7 +1677,7 @@ function defineVideoController() {
log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5); log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5);
}; };
tc.videoController.prototype.setupYouTubeAutoHide = function (wrapper) { tc.videoController.prototype.setupYouTubeAutoHide = function(wrapper) {
if (!wrapper || !isOnYouTube()) return; if (!wrapper || !isOnYouTube()) return;
const video = this.video; const video = this.video;
@@ -1656,7 +1693,7 @@ function defineVideoController() {
// The vsc-hidden class (from V key) takes precedence via CSS specificity // The vsc-hidden class (from V key) takes precedence via CSS specificity
if (ytPlayer.classList.contains("ytp-autohide")) { if (ytPlayer.classList.contains("ytp-autohide")) {
wrapper.classList.add("ytp-autohide"); wrapper.classList.add("ytp-autohide");
// Immediately end any temporary "vsc-show" state to hide with YouTube // Immediately end any temporary "vsc-show" state to hide with YouTube
// UNLESS it was forced by a shortcut (vsc-forced-show) // UNLESS it was forced by a shortcut (vsc-forced-show)
if (!wrapper.classList.contains("vsc-forced-show")) { if (!wrapper.classList.contains("vsc-forced-show")) {
@@ -1666,7 +1703,7 @@ function defineVideoController() {
wrapper.showTimeOut = undefined; wrapper.showTimeOut = undefined;
} }
} }
log("YouTube controls hidden, hiding controller", 5); log("YouTube controls hidden, hiding controller", 5);
} else { } else {
wrapper.classList.remove("ytp-autohide"); wrapper.classList.remove("ytp-autohide");
@@ -1716,7 +1753,7 @@ function defineVideoController() {
}; };
}; };
tc.videoController.prototype.setupGenericAutoHide = function (wrapper) { tc.videoController.prototype.setupGenericAutoHide = function(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const video = this.video; const video = this.video;
@@ -1775,7 +1812,7 @@ function defineVideoController() {
log(`Generic auto-hide setup complete with ${tc.settings.hideWithControlsTimer}s timer`, 4); log(`Generic auto-hide setup complete with ${tc.settings.hideWithControlsTimer}s timer`, 4);
}; };
tc.videoController.prototype.initializeControls = function () { tc.videoController.prototype.initializeControls = function() {
const doc = this.video.ownerDocument; const doc = this.video.ownerDocument;
const speed = this.video.playbackRate.toFixed(2); const speed = this.video.playbackRate.toFixed(2);
var wrapper = doc.createElement("div"); var wrapper = doc.createElement("div");
@@ -1813,7 +1850,7 @@ function defineVideoController() {
var subtitleNudgeIndicator = null; var subtitleNudgeIndicator = null;
buttonConfig.forEach(function (btnId) { buttonConfig.forEach(function(btnId) {
if (btnId === "nudge") { if (btnId === "nudge") {
subtitleNudgeIndicator = doc.createElement("span"); subtitleNudgeIndicator = doc.createElement("span");
subtitleNudgeIndicator.id = "nudge-indicator"; subtitleNudgeIndicator.id = "nudge-indicator";
@@ -1836,24 +1873,36 @@ function defineVideoController() {
nudgeFlashIndicator.setAttribute("aria-hidden", "true"); nudgeFlashIndicator.setAttribute("aria-hidden", "true");
controller.appendChild(dragHandle); controller.appendChild(dragHandle);
controller.appendChild(nudgeFlashIndicator);
controller.appendChild(controls); controller.appendChild(controls);
/* Flash sits after #controls so it never inserts space between speed and buttons. */
controller.appendChild(nudgeFlashIndicator);
shadow.appendChild(controller); shadow.appendChild(controller);
this.speedIndicator = dragHandle; this.speedIndicator = dragHandle;
this.subtitleNudgeIndicator = subtitleNudgeIndicator; this.subtitleNudgeIndicator = subtitleNudgeIndicator;
this.nudgeFlashIndicator = nudgeFlashIndicator; this.nudgeFlashIndicator = nudgeFlashIndicator;
this.resetButtonEl =
shadow.querySelector("button[data-action=\"reset\"]") || null;
this.resetToggleArmed = false;
if (subtitleNudgeIndicator) { if (subtitleNudgeIndicator) {
updateSubtitleNudgeIndicator(this.video); updateSubtitleNudgeIndicator(this.video);
} }
function blurAfterPointerTap(target, e) {
if (!target || typeof target.blur !== "function") return;
var pt = e.pointerType;
if (pt === "mouse" || pt === "touch" || (!pt && e.detail > 0)) {
requestAnimationFrame(function() {
target.blur();
});
}
}
dragHandle.addEventListener( dragHandle.addEventListener(
"mousedown", "mousedown",
(e) => { (e) => {
runAction( var dragAction = dragHandle.dataset.action;
e.target.dataset["action"], runAction(dragAction, getKeyBindings(dragAction, "value"), e);
getKeyBindings(e.target.dataset["action"], "value"),
e
);
e.stopPropagation(); e.stopPropagation();
}, },
true true
@@ -1862,11 +1911,9 @@ function defineVideoController() {
button.addEventListener( button.addEventListener(
"click", "click",
(e) => { (e) => {
runAction( var action = button.dataset.action;
e.target.dataset["action"], runAction(action, getKeyBindings(action), e);
getKeyBindings(e.target.dataset["action"]), blurAfterPointerTap(button, e);
e
);
e.stopPropagation(); e.stopPropagation();
}, },
true true
@@ -1881,6 +1928,7 @@ function defineVideoController() {
var newState = !isSubtitleNudgeEnabledForVideo(video); var newState = !isSubtitleNudgeEnabledForVideo(video);
setSubtitleNudgeEnabledForVideo(video, newState); setSubtitleNudgeEnabledForVideo(video, newState);
} }
blurAfterPointerTap(subtitleNudgeIndicator, e);
e.stopPropagation(); e.stopPropagation();
}, },
true true
@@ -1897,7 +1945,7 @@ function defineVideoController() {
this.setupGenericAutoHide(wrapper); this.setupGenericAutoHide(wrapper);
} }
} }
var fragment = doc.createDocumentFragment(); var fragment = doc.createDocumentFragment();
fragment.appendChild(wrapper); fragment.appendChild(wrapper);
const parentEl = this.parent || this.video.parentElement; const parentEl = this.parent || this.video.parentElement;
@@ -1963,10 +2011,6 @@ function defineVideoController() {
}; };
} }
function escapeStringRegExp(str) {
const m = /[|\\{}()[\]^$+*?.]/g;
return str.replace(m, "\\$&");
}
function applySiteRuleOverrides() { function applySiteRuleOverrides() {
resetSettingsFromSiteRuleBase(); resetSettingsFromSiteRuleBase();
@@ -1975,34 +2019,7 @@ function applySiteRuleOverrides() {
} }
var currentUrl = location.href; var currentUrl = location.href;
var matchedRule = null; var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
for (var i = 0; i < tc.settings.siteRules.length; i++) {
var rule = tc.settings.siteRules[i];
var pattern = rule.pattern;
if (!pattern || pattern.length === 0) continue;
var regex;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var lastSlash = pattern.lastIndexOf("/");
regex = new RegExp(
pattern.substring(1, lastSlash),
pattern.substring(lastSlash + 1)
);
} catch (e) {
log(`Invalid site rule regex: ${pattern}. ${e.message}`, 2);
continue;
}
} else {
regex = new RegExp(escapeStringRegExp(pattern));
}
if (regex && regex.test(currentUrl)) {
matchedRule = rule;
break;
}
}
if (!matchedRule) return false; if (!matchedRule) return false;
@@ -2010,13 +2027,9 @@ function applySiteRuleOverrides() {
log(`Matched site rule: ${matchedRule.pattern}`, 4); log(`Matched site rule: ${matchedRule.pattern}`, 4);
// Check if extension should be enabled/disabled on this site // Check if extension should be enabled/disabled on this site
if (matchedRule.enabled === false) { if (siteRuleUtils.isSiteRuleDisabled(matchedRule)) {
log(`Extension disabled for site: ${currentUrl}`, 4); log(`Extension disabled for site: ${currentUrl}`, 4);
return true; return true;
} else if (matchedRule.disableExtension === true) {
// Handle old format
log(`Extension disabled (legacy) for site: ${currentUrl}`, 4);
return true;
} }
// Override general settings with site-specific overrides // Override general settings with site-specific overrides
@@ -2045,7 +2058,7 @@ function applySiteRuleOverrides() {
[ [
"controllerMarginTop", "controllerMarginTop",
"controllerMarginBottom" "controllerMarginBottom"
].forEach(function (key) { ].forEach(function(key) {
tc.settings[key] = normalizeControllerMarginPx(tc.settings[key], 0); tc.settings[key] = normalizeControllerMarginPx(tc.settings[key], 0);
}); });
@@ -2074,6 +2087,25 @@ function applySiteRuleOverrides() {
return false; return false;
} }
/** Apply current tc.settings controller layout/opacity to every attached controller (after site rules). */
function refreshAllControllerGeometry() {
tc.mediaElements.forEach(function(video) {
if (!video || !video.vsc) return;
applyControllerLocation(video.vsc, tc.settings.controllerLocation);
var controllerEl = getControllerElement(video.vsc);
if (controllerEl) {
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
}
});
}
/** Re-match site rules for current URL and refresh controller position/opacity on every video. */
function reapplySiteRulesAndControllerGeometry() {
var siteDisabled = applySiteRuleOverrides();
if (!tc.settings.enabled || siteDisabled) return;
refreshAllControllerGeometry();
}
function shouldPreserveDesiredSpeed(video, speed) { function shouldPreserveDesiredSpeed(video, speed) {
if (!video || !video.vsc) return false; if (!video || !video.vsc) return false;
var desiredSpeed = getDesiredSpeed(video); var desiredSpeed = getDesiredSpeed(video);
@@ -2091,8 +2123,12 @@ function shouldPreserveDesiredSpeed(video, speed) {
function setupListener(root) { function setupListener(root) {
root = root || document; root = root || document;
if (root.vscRateListenerAttached) return; if (root.vscRateListenerAttached) return;
function updateSpeedFromEvent(video) {
function updateSpeedFromEvent(video, skipResetDisarm) {
if (!video.vsc || !video.vsc.speedIndicator) return; if (!video.vsc || !video.vsc.speedIndicator) return;
if (!skipResetDisarm) {
video.vsc.resetToggleArmed = false;
}
var speed = video.playbackRate; // Preserve full precision (e.g. 0.0625) var speed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
video.vsc.speedIndicator.textContent = speed.toFixed(2); video.vsc.speedIndicator.textContent = speed.toFixed(2);
video.vsc.targetSpeed = speed; video.vsc.targetSpeed = speed;
@@ -2108,9 +2144,10 @@ function setupListener(root) {
else video.vsc.startSubtitleNudge(); else video.vsc.startSubtitleNudge();
} }
} }
root.addEventListener( root.addEventListener(
"ratechange", "ratechange",
function (event) { function(event) {
if (tc.isNudging) return; if (tc.isNudging) return;
var video = event.target; var video = event.target;
if (!video || typeof video.playbackRate === "undefined" || !video.vsc) if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
@@ -2119,7 +2156,7 @@ function setupListener(root) {
if (tc.settings.forceLastSavedSpeed) { if (tc.settings.forceLastSavedSpeed) {
if (event.detail && event.detail.origin === "videoSpeed") { if (event.detail && event.detail.origin === "videoSpeed") {
video.playbackRate = event.detail.speed; video.playbackRate = event.detail.speed;
updateSpeedFromEvent(video); updateSpeedFromEvent(video, true);
} else { } else {
video.playbackRate = sanitizeSpeed(tc.settings.lastSpeed, 1.0); video.playbackRate = sanitizeSpeed(tc.settings.lastSpeed, 1.0);
} }
@@ -2130,7 +2167,7 @@ function setupListener(root) {
var pendingRateChange = takePendingRateChange(video, currentSpeed); var pendingRateChange = takePendingRateChange(video, currentSpeed);
if (pendingRateChange) { if (pendingRateChange) {
updateSpeedFromEvent(video); updateSpeedFromEvent(video, true);
return; return;
} }
@@ -2139,6 +2176,7 @@ function setupListener(root) {
`Ignoring external rate change to ${currentSpeed.toFixed(4)} while preserving ${desiredSpeed.toFixed(4)}`, `Ignoring external rate change to ${currentSpeed.toFixed(4)} while preserving ${desiredSpeed.toFixed(4)}`,
4 4
); );
video.vsc.resetToggleArmed = false;
video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2); video.vsc.speedIndicator.textContent = desiredSpeed.toFixed(2);
scheduleSpeedRestore(video, desiredSpeed, "pause/play or seek"); scheduleSpeedRestore(video, desiredSpeed, "pause/play or seek");
return; return;
@@ -2153,6 +2191,7 @@ function setupListener(root) {
} }
var vscInitializedDocuments = new Set(); var vscInitializedDocuments = new Set();
function clearPendingInitialization(doc) { function clearPendingInitialization(doc) {
if (!doc || !doc.vscPendingInitializeHandler) return; if (!doc || !doc.vscPendingInitializeHandler) return;
@@ -2189,7 +2228,7 @@ function initializeWhenReady(doc, forceReinit = false) {
if (doc.vscPendingInitializeHandler) return; if (doc.vscPendingInitializeHandler) return;
var pendingInitializeHandler = function () { var pendingInitializeHandler = function() {
tryInitializeDocument(doc, doc.vscPendingForceReinit === true); tryInitializeDocument(doc, doc.vscPendingForceReinit === true);
}; };
@@ -2204,6 +2243,7 @@ function initializeWhenReady(doc, forceReinit = false) {
setTimeout(pendingInitializeHandler, 0); setTimeout(pendingInitializeHandler, 0);
} }
} }
function inIframe() { function inIframe() {
try { try {
return window.self !== window.top; return window.self !== window.top;
@@ -2216,13 +2256,14 @@ function attachKeydownListeners(doc) {
var docs = [doc]; var docs = [doc];
try { try {
if (inIframe() && window.top.document !== doc) docs.push(window.top.document); if (inIframe() && window.top.document !== doc) docs.push(window.top.document);
} catch (e) { } } catch (e) {
}
docs.forEach(function (keyDoc) { docs.forEach(function(keyDoc) {
if (keyDoc.vscKeydownListenerAttached) return; if (keyDoc.vscKeydownListenerAttached) return;
keyDoc.addEventListener( keyDoc.addEventListener(
"keydown", "keydown",
function (event) { function(event) {
if ( if (
!event.getModifierState || !event.getModifierState ||
event.getModifierState("Alt") || event.getModifierState("Alt") ||
@@ -2245,7 +2286,7 @@ function attachKeydownListeners(doc) {
if (!tc.mediaElements.length) return; if (!tc.mediaElements.length) return;
var item = tc.settings.keyBindings.find(function (binding) { var item = tc.settings.keyBindings.find(function(binding) {
return matchesKeyBinding(binding, event); return matchesKeyBinding(binding, event);
}); });
@@ -2269,24 +2310,24 @@ function attachMutationObserver(root) {
var pendingMutations = []; var pendingMutations = [];
var mutationProcessingScheduled = false; var mutationProcessingScheduled = false;
var observer = new MutationObserver(function (mutations) { var observer = new MutationObserver(function(mutations) {
pendingMutations.push(...mutations); pendingMutations.push(...mutations);
if (mutationProcessingScheduled) return; if (mutationProcessingScheduled) return;
mutationProcessingScheduled = true; mutationProcessingScheduled = true;
requestIdle( requestIdle(
function () { function() {
var mutationsToProcess = pendingMutations.splice(0); var mutationsToProcess = pendingMutations.splice(0);
mutationProcessingScheduled = false; mutationProcessingScheduled = false;
mutationsToProcess.forEach(function (mutation) { mutationsToProcess.forEach(function(mutation) {
if (mutation.type === "childList") { if (mutation.type === "childList") {
mutation.addedNodes.forEach(function (node) { mutation.addedNodes.forEach(function(node) {
// Skip text nodes, comments, etc. — only elements can contain media // Skip text nodes, comments, etc. — only elements can contain media
if (node.nodeType !== Node.ELEMENT_NODE) return; if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, true); scanNodeForMedia(node, node.parentNode || mutation.target, true);
}); });
mutation.removedNodes.forEach(function (node) { mutation.removedNodes.forEach(function(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return; if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, false); scanNodeForMedia(node, node.parentNode || mutation.target, false);
}); });
@@ -2335,7 +2376,7 @@ function attachMutationObserver(root) {
function attachMediaDetectionListeners(root) { function attachMediaDetectionListeners(root) {
if (root.vscMediaEventListenersAttached) return; if (root.vscMediaEventListenersAttached) return;
var handleDetectedMedia = function (event) { var handleDetectedMedia = function(event) {
var target = event.target; var target = event.target;
if (!isMediaElement(target)) return; if (!isMediaElement(target)) return;
ensureController(target, target.parentElement || target.parentNode); ensureController(target, target.parentElement || target.parentNode);
@@ -2348,21 +2389,22 @@ function attachMediaDetectionListeners(root) {
"canplay", "canplay",
"playing", "playing",
"play" "play"
].forEach(function (eventName) { ].forEach(function(eventName) {
root.addEventListener(eventName, handleDetectedMedia, true); root.addEventListener(eventName, handleDetectedMedia, true);
}); });
root.vscMediaEventListenersAttached = true; root.vscMediaEventListenersAttached = true;
} }
function attachIframeListeners(doc) { function attachIframeListeners(doc) {
Array.from(doc.getElementsByTagName("iframe")).forEach(function (frame) { Array.from(doc.getElementsByTagName("iframe")).forEach(function(frame) {
if (!frame.vscLoadListenerAttached) { if (!frame.vscLoadListenerAttached) {
frame.addEventListener("load", function () { frame.addEventListener("load", function() {
try { try {
if (frame.contentDocument) { if (frame.contentDocument) {
initializeWhenReady(frame.contentDocument, true); initializeWhenReady(frame.contentDocument, true);
} }
} catch (e) { } } catch (e) {
}
}); });
frame.vscLoadListenerAttached = true; frame.vscLoadListenerAttached = true;
} }
@@ -2371,24 +2413,25 @@ function attachIframeListeners(doc) {
if (frame.contentDocument) { if (frame.contentDocument) {
initializeWhenReady(frame.contentDocument); initializeWhenReady(frame.contentDocument);
} }
} catch (e) { } } catch (e) {
}
}); });
} }
function attachNavigationListeners() { function attachNavigationListeners() {
if (window.vscNavigationListenersAttached) return; if (window.vscNavigationListenersAttached) return;
var scheduleRescan = function () { var scheduleRescan = function() {
clearTimeout(window.vscNavigationRescanTimer); clearTimeout(window.vscNavigationRescanTimer);
window.vscNavigationRescanTimer = setTimeout(function () { window.vscNavigationRescanTimer = setTimeout(function() {
initializeWhenReady(document, true); initializeWhenReady(document, true);
}, 300); }, 300);
}; };
["pushState", "replaceState"].forEach(function (method) { ["pushState", "replaceState"].forEach(function(method) {
if (typeof history[method] !== "function") return; if (typeof history[method] !== "function") return;
var original = history[method]; var original = history[method];
history[method] = function () { history[method] = function() {
var result = original.apply(this, arguments); var result = original.apply(this, arguments);
scheduleRescan(); scheduleRescan();
return result; return result;
@@ -2397,6 +2440,10 @@ function attachNavigationListeners() {
window.addEventListener("popstate", scheduleRescan); window.addEventListener("popstate", scheduleRescan);
window.addEventListener("hashchange", scheduleRescan); window.addEventListener("hashchange", scheduleRescan);
/* YouTube often navigates without a history API call the extension can see first */
if (typeof document !== "undefined" && isOnYouTube()) {
document.addEventListener("yt-navigate-finish", scheduleRescan);
}
window.vscNavigationListenersAttached = true; window.vscNavigationListenersAttached = true;
} }
@@ -2416,20 +2463,19 @@ function initializeNow(doc, forceReinit = false) {
if (forceReinit) { if (forceReinit) {
log("Force re-initialization requested", 4); log("Force re-initialization requested", 4);
tc.mediaElements.forEach(function (video) { refreshAllControllerGeometry();
if (!video || !video.vsc) return;
applyControllerLocation(video.vsc, tc.settings.controllerLocation);
var controllerEl = getControllerElement(video.vsc);
if (controllerEl) {
controllerEl.style.opacity = String(tc.settings.controllerOpacity);
}
});
} }
vscInitializedDocuments.add(doc); vscInitializedDocuments.add(doc);
} }
function setSpeed(video, speed, isInitialCall = false, isUserKeyPress = false) { function setSpeed(
video,
speed,
isInitialCall = false,
isUserKeyPress = false,
fromResetSpeedToggle = false
) {
const numericSpeed = Number(speed); const numericSpeed = Number(speed);
if (!isValidSpeed(numericSpeed)) { if (!isValidSpeed(numericSpeed)) {
@@ -2442,6 +2488,10 @@ function setSpeed(video, speed, isInitialCall = false, isUserKeyPress = false) {
if (!video || !video.vsc || !video.vsc.speedIndicator) return; if (!video || !video.vsc || !video.vsc.speedIndicator) return;
if (isUserKeyPress && !fromResetSpeedToggle) {
video.vsc.resetToggleArmed = false;
}
log( log(
`setSpeed: Target ${numericSpeed.toFixed(2)}. Initial: ${isInitialCall}. UserKeyPress: ${isUserKeyPress}`, `setSpeed: Target ${numericSpeed.toFixed(2)}. Initial: ${isInitialCall}. UserKeyPress: ${isUserKeyPress}`,
4 4
@@ -2528,7 +2578,7 @@ function runAction(action, value, e) {
); );
} }
mediaTagsToProcess.forEach(function (v) { mediaTagsToProcess.forEach(function(v) {
if (!v.vsc) return; // Don't process videos without a controller if (!v.vsc) return; // Don't process videos without a controller
var controller = v.vsc.div; var controller = v.vsc.div;
const userDrivenActionsThatShowController = [ const userDrivenActionsThatShowController = [
@@ -2544,10 +2594,18 @@ function runAction(action, value, e) {
"mark", "mark",
"jump", "jump",
"drag", "drag",
"nudge",
"toggleSubtitleNudge", "toggleSubtitleNudge",
"display" "display"
]; ];
if (userDrivenActionsThatShowController.includes(action) && action !== "display") { var subtitleNudgeActionBlocked =
(action === "toggleSubtitleNudge" || action === "nudge") &&
!isSubtitleNudgeAvailableForVideo(v);
if (
userDrivenActionsThatShowController.includes(action) &&
action !== "display" &&
!subtitleNudgeActionBlocked
) {
showController(controller, 2000, true); showController(controller, 2000, true);
} }
if (v.classList.contains("vsc-cancelled")) return; if (v.classList.contains("vsc-cancelled")) return;
@@ -2659,6 +2717,12 @@ function runAction(action, value, e) {
case "toggleSubtitleNudge": case "toggleSubtitleNudge":
setSubtitleNudgeEnabledForVideo(v, subtitleNudgeToggleValue); setSubtitleNudgeEnabledForVideo(v, subtitleNudgeToggleValue);
break; break;
case "nudge":
setSubtitleNudgeEnabledForVideo(
v,
!isSubtitleNudgeEnabledForVideo(v)
);
break;
} }
}); });
log("runAction End", 5); log("runAction End", 5);
@@ -2697,11 +2761,12 @@ function resetSpeed(v, target, isFastKey = false) {
Math.abs(lastToggle - 1.0) < 0.01 Math.abs(lastToggle - 1.0) < 0.01
? getKeyBindings("fast") || 1.8 ? getKeyBindings("fast") || 1.8
: lastToggle; : lastToggle;
setSpeed(v, speedToRestore, false, true); setSpeed(v, speedToRestore, false, true, true);
} else { } else {
// Not at 1.0, save current as toggle speed and go to 1.0 // Not at 1.0, save current as toggle speed and go to 1.0
lastToggleSpeed[videoId] = currentSpeed; lastToggleSpeed[videoId] = currentSpeed;
setSpeed(v, resetSpeedValue, false, true); v.vsc.resetToggleArmed = true;
setSpeed(v, resetSpeedValue, false, true, true);
} }
} }
} }
@@ -2709,15 +2774,18 @@ function resetSpeed(v, target, isFastKey = false) {
function muted(v) { function muted(v) {
v.muted = !v.muted; v.muted = !v.muted;
} }
function setMark(v) { function setMark(v) {
v.vsc.mark = v.currentTime; v.vsc.mark = v.currentTime;
} }
function jumpToMark(v) { function jumpToMark(v) {
if (v.vsc && typeof v.vsc.mark === "number") { if (v.vsc && typeof v.vsc.mark === "number") {
extendSpeedRestoreWindow(v); extendSpeedRestoreWindow(v);
v.currentTime = v.vsc.mark; v.currentTime = v.vsc.mark;
} }
} }
function handleDrag(video, e) { function handleDrag(video, e) {
const c = video.vsc.div; const c = video.vsc.div;
const sC = convertControllerToManualPosition(video.vsc); const sC = convertControllerToManualPosition(video.vsc);
@@ -2727,7 +2795,7 @@ function handleDrag(video, e) {
pE.parentNode && pE.parentNode &&
pE.parentNode.offsetHeight === pE.offsetHeight && pE.parentNode.offsetHeight === pE.offsetHeight &&
pE.parentNode.offsetWidth === pE.offsetWidth pE.parentNode.offsetWidth === pE.offsetWidth
) )
pE = pE.parentNode; pE = pE.parentNode;
video.classList.add("vcs-dragging"); video.classList.add("vcs-dragging");
sC.classList.add("dragging"); sC.classList.add("dragging");
@@ -2756,6 +2824,7 @@ function handleDrag(video, e) {
pE.addEventListener("mouseleave", eD); pE.addEventListener("mouseleave", eD);
pE.addEventListener("mousemove", sD); pE.addEventListener("mousemove", sD);
} }
function showController(controller, duration = 2000, forced = false) { function showController(controller, duration = 2000, forced = false) {
if (!controller || typeof controller.classList === "undefined") return; if (!controller || typeof controller.classList === "undefined") return;
var restoreHidden = var restoreHidden =
@@ -2773,7 +2842,7 @@ function showController(controller, duration = 2000, forced = false) {
clearTimeout(controller.showTimeOut); clearTimeout(controller.showTimeOut);
} }
controller.showTimeOut = setTimeout(function () { controller.showTimeOut = setTimeout(function() {
controller.classList.remove("vsc-show"); controller.classList.remove("vsc-show");
controller.classList.remove("vsc-forced-show"); controller.classList.remove("vsc-forced-show");
if (controller.restoreHiddenAfterShow === true) { if (controller.restoreHiddenAfterShow === true) {
+150
View File
@@ -0,0 +1,150 @@
/**
* Lucide static icons via jsDelivr (same registry as lucide.dev).
* ISC License — https://lucide.dev
*/
var LUCIDE_STATIC_VERSION = "1.7.0";
var LUCIDE_CDN_BASE =
"https://cdn.jsdelivr.net/npm/lucide-static@" +
LUCIDE_STATIC_VERSION;
var LUCIDE_TAGS_CACHE_KEY = "lucideTagsCacheV1";
var LUCIDE_TAGS_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 7; /* 7 days */
function lucideIconSvgUrl(slug) {
if (!slug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(slug)) {
return "";
}
return LUCIDE_CDN_BASE + "/icons/" + slug.toLowerCase() + ".svg";
}
function lucideTagsJsonUrl() {
return LUCIDE_CDN_BASE + "/tags.json";
}
/** Collapse whitespace for smaller storage. */
function lucideMinifySvg(s) {
return String(s).replace(/\s+/g, " ").trim();
}
function sanitizeLucideSvg(svgText) {
if (!svgText || typeof svgText !== "string") return null;
var t = String(svgText).replace(/\0/g, "").trim();
if (!/<svg[\s>]/i.test(t)) return null;
var doc = new DOMParser().parseFromString(t, "image/svg+xml");
if (doc.querySelector("parsererror")) return null;
var svg = vscSanitizeSvgTree(doc.querySelector("svg"));
if (!svg) return null;
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
svg.setAttribute("aria-hidden", "true");
return lucideMinifySvg(svg.outerHTML);
}
function fetchLucideSvg(slug) {
var url = lucideIconSvgUrl(slug);
if (!url) {
return Promise.reject(new Error("Invalid icon name"));
}
return fetch(url, { cache: "force-cache" }).then(function (r) {
if (!r.ok) {
throw new Error("Icon not found: " + slug);
}
return r.text();
});
}
function fetchAndCacheLucideTags(chromeLocal, resolve, reject) {
fetch(lucideTagsJsonUrl(), { cache: "force-cache" })
.then(function (r) {
if (!r.ok) throw new Error("tags.json HTTP " + r.status);
return r.json();
})
.then(function (obj) {
var payload = {};
payload[LUCIDE_TAGS_CACHE_KEY] = obj;
payload[LUCIDE_TAGS_CACHE_KEY + "At"] = Date.now();
if (chromeLocal && chromeLocal.set) {
chromeLocal.set(payload, function () {
resolve(obj);
});
} else {
resolve(obj);
}
})
.catch(reject);
}
/** @returns {Promise<Object<string, string[]>>} slug -> tags */
function getLucideTagsMap(chromeLocal, bypassCache) {
return new Promise(function (resolve, reject) {
if (!chromeLocal || !chromeLocal.get) {
fetch(lucideTagsJsonUrl(), { cache: "force-cache" })
.then(function (r) {
return r.json();
})
.then(resolve)
.catch(reject);
return;
}
if (bypassCache) {
fetchAndCacheLucideTags(chromeLocal, resolve, reject);
return;
}
chromeLocal.get(
[LUCIDE_TAGS_CACHE_KEY, LUCIDE_TAGS_CACHE_KEY + "At"],
function (stored) {
if (chrome.runtime.lastError) {
fetchAndCacheLucideTags(chromeLocal, resolve, reject);
return;
}
var data = stored[LUCIDE_TAGS_CACHE_KEY];
var at = stored[LUCIDE_TAGS_CACHE_KEY + "At"];
if (
data &&
typeof data === "object" &&
at &&
Date.now() - at < LUCIDE_TAGS_MAX_AGE_MS
) {
resolve(data);
return;
}
fetchAndCacheLucideTags(chromeLocal, resolve, reject);
}
);
});
}
/**
* @param {Object<string,string[]>} tagsMap
* @param {string} query
* @param {number} limit
* @returns {string[]} slugs
*/
function searchLucideSlugs(tagsMap, query, limit) {
var lim = limit != null ? limit : 60;
var q = String(query || "")
.toLowerCase()
.trim();
if (!tagsMap || !q) return [];
var matches = [];
for (var slug in tagsMap) {
if (!Object.prototype.hasOwnProperty.call(tagsMap, slug)) continue;
var hay =
slug +
" " +
(Array.isArray(tagsMap[slug]) ? tagsMap[slug].join(" ") : "");
if (hay.toLowerCase().indexOf(q) === -1) continue;
matches.push(slug);
}
matches.sort(function (a, b) {
var al = a.toLowerCase();
var bl = b.toLowerCase();
var ap = al.indexOf(q) === 0 ? 0 : 1;
var bp = bl.indexOf(q) === 0 ? 0 : 1;
if (ap !== bp) return ap - bp;
return al.localeCompare(bl);
});
return matches.slice(0, lim);
}
+31 -9
View File
@@ -1,7 +1,7 @@
{ {
"name": "Speeder", "name": "Speeder",
"short_name": "Speeder", "short_name": "Speeder",
"version": "5.0.2", "version": "5.1.9.0",
"manifest_version": 2, "manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")", "description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
"homepage_url": "https://github.com/SoPat712/speeder", "homepage_url": "https://github.com/SoPat712/speeder",
@@ -9,7 +9,9 @@
"gecko": { "gecko": {
"id": "{ed860648-f54f-4dc9-9a0d-501aec4313f5}", "id": "{ed860648-f54f-4dc9-9a0d-501aec4313f5}",
"data_collection_permissions": { "data_collection_permissions": {
"required": ["none"] "required": [
"none"
]
} }
} }
}, },
@@ -19,12 +21,17 @@
"128": "icons/icon128.png" "128": "icons/icon128.png"
}, },
"background": { "background": {
"scripts": ["background.js"] "scripts": [
"background.js"
]
}, },
"permissions": ["storage"], "permissions": [
"storage",
"https://cdn.jsdelivr.net/*"
],
"options_ui": { "options_ui": {
"page": "options.html", "page": "options.html",
"open_in_tab": false "open_in_tab": true
}, },
"browser_action": { "browser_action": {
"default_icon": { "default_icon": {
@@ -37,16 +44,31 @@
"content_scripts": [ "content_scripts": [
{ {
"all_frames": true, "all_frames": true,
"matches": ["http://*/*", "https://*/*", "file:///*"], "matches": [
"http://*/*",
"https://*/*",
"file:///*"
],
"match_about_blank": true, "match_about_blank": true,
"exclude_matches": [ "exclude_matches": [
"https://plus.google.com/hangouts/*", "https://plus.google.com/hangouts/*",
"https://hangouts.google.com/*", "https://hangouts.google.com/*",
"https://meet.google.com/*" "https://meet.google.com/*"
], ],
"css": ["inject.css"], "css": [
"js": ["inject.js"] "inject.css"
],
"js": [
"shared/controller-utils.js",
"shared/key-bindings.js",
"shared/site-rules.js",
"ui-icons.js",
"inject.js"
]
} }
], ],
"web_accessible_resources": ["inject.css", "shadow.css"] "web_accessible_resources": [
"inject.css",
"shadow.css"
]
} }
+482 -39
View File
@@ -7,6 +7,17 @@
--text: #17191c; --text: #17191c;
--muted: #626b76; --muted: #626b76;
--accent: #111827; --accent: #111827;
--switch-track-off: #c1cad6;
--switch-track-off-border: #aeb8c5;
--switch-track-on: #111827;
--switch-track-on-border: #111827;
--switch-thumb-off: #ffffff;
--switch-thumb-on: #ffffff;
--toggle-open-fg: #111827;
--toggle-open-bg: #eef2f6;
--toggle-open-border: #c5ccd5;
--toggle-open-hover-bg: #e4eaf1;
--toggle-open-hover-border: #b5c0cc;
--danger: #b42318; --danger: #b42318;
} }
@@ -15,12 +26,16 @@
} }
html { html {
min-height: 100%; /* Avoid coupling to the browser viewport: embedded options (e.g. Add-ons
* Manager iframe) must size to content, not 100vh, or a large empty band
* appears below the page. */
height: auto;
min-height: 0;
} }
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 0;
padding: 24px 16px 40px; padding: 24px 16px 40px;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
@@ -49,6 +64,7 @@ body {
} }
h1, h1,
h2,
h3, h3,
h4 { h4 {
margin: 0; margin: 0;
@@ -104,6 +120,35 @@ h4 {
background: var(--panel); background: var(--panel);
} }
.control-bars-group {
padding: 20px;
}
.control-bars-inner {
display: grid;
gap: 10px;
}
.settings-card-nested {
background: var(--panel-subtle);
border-radius: 10px;
}
.section-heading-major {
margin-bottom: 14px;
}
.section-heading-major h2 {
margin: 0 0 6px;
font-size: 18px;
font-weight: 650;
letter-spacing: -0.02em;
}
.section-heading-major .section-intro {
margin-top: 0;
}
.section-heading { .section-heading {
margin-bottom: 10px; margin-bottom: 10px;
} }
@@ -176,6 +221,7 @@ button:active {
} }
button:focus-visible, button:focus-visible,
input[type="checkbox"]:focus-visible,
input[type="text"]:focus, input[type="text"]:focus,
select:focus, select:focus,
textarea:focus { textarea:focus {
@@ -213,10 +259,49 @@ textarea:focus {
} }
input[type="checkbox"] { input[type="checkbox"] {
width: 16px; appearance: none;
height: 16px; -webkit-appearance: none;
margin: 2px 0 0; position: relative;
accent-color: var(--accent); width: 46px;
min-width: 46px;
height: 28px;
margin: 0;
border: 1px solid var(--switch-track-off-border);
border-radius: 999px;
background: var(--switch-track-off);
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease,
box-shadow 120ms ease;
flex-shrink: 0;
}
input[type="checkbox"]::before {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--switch-thumb-off);
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.18),
inset 0 0 0 1px rgba(17, 24, 39, 0.08);
transition: transform 120ms ease, background-color 120ms ease;
}
input[type="checkbox"]:checked {
background: var(--switch-track-on);
border-color: var(--switch-track-on-border);
}
input[type="checkbox"]:checked::before {
transform: translateX(18px);
background: var(--switch-thumb-on);
}
input[type="checkbox"]:disabled {
cursor: default;
opacity: 0.7;
} }
label { label {
@@ -288,6 +373,39 @@ label em {
.toggle-site-rule { .toggle-site-rule {
font-weight: 400; font-weight: 400;
color: var(--muted);
}
.toggle-site-rule:hover {
color: var(--toggle-open-fg);
background: var(--toggle-open-hover-bg);
border-color: var(--toggle-open-hover-border);
}
.toggle-site-rule .site-rule-toggle-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
pointer-events: none;
}
.toggle-site-rule .site-rule-toggle-icon svg {
width: 18px;
height: 18px;
}
.site-rule:not(.collapsed) .toggle-site-rule {
color: var(--toggle-open-fg);
background: var(--toggle-open-bg);
border-color: var(--toggle-open-border);
}
.site-rule:not(.collapsed) .toggle-site-rule:hover {
color: var(--toggle-open-fg);
background: var(--toggle-open-hover-bg);
border-color: var(--toggle-open-hover-border);
} }
.row { .row {
@@ -299,6 +417,19 @@ label em {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.row input[type="text"],
.row select {
justify-self: end;
}
.row.row-checkbox {
grid-template-columns: minmax(0, 1fr) auto;
}
.row.row-checkbox input[type="checkbox"] {
justify-self: end;
}
.settings-card .row:first-of-type { .settings-card .row:first-of-type {
padding-top: 0; padding-top: 0;
border-top: 0; border-top: 0;
@@ -310,16 +441,17 @@ label em {
.controller-margin-inputs { .controller-margin-inputs {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 116px));
gap: 8px; gap: 8px;
width: 100%; width: max-content;
justify-self: end;
} }
.margin-pad-cell { .margin-pad-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
min-width: 0; min-width: 116px;
} }
.margin-pad-mini { .margin-pad-mini {
@@ -332,12 +464,13 @@ label em {
.controller-margin-inputs input[type="text"] { .controller-margin-inputs input[type="text"] {
width: 100%; width: 100%;
min-width: 0; min-width: 116px;
box-sizing: border-box; box-sizing: border-box;
text-align: right;
} }
.site-rule-option.site-rule-margin-option { .site-rule-option.site-rule-margin-option {
grid-template-columns: minmax(0, 1fr) minmax(0, 220px); grid-template-columns: minmax(0, 1fr) minmax(0, 260px);
} }
.site-rule-override-section { .site-rule-override-section {
@@ -353,17 +486,22 @@ label em {
} }
.site-override-lead { .site-override-lead {
display: flex; display: grid;
align-items: flex-start; grid-template-columns: minmax(0, 1fr) auto;
gap: 10px; gap: 16px;
align-items: center;
font-weight: 600; font-weight: 600;
margin-bottom: 8px; margin-bottom: 8px;
cursor: pointer; cursor: pointer;
width: auto; width: 100%;
} }
.site-override-lead input { .site-override-lead input[type="checkbox"] {
margin-top: 3px; justify-self: end;
}
.site-override-lead span {
margin: 0;
} }
.site-rule-override-section .site-override-fields, .site-rule-override-section .site-override-fields,
@@ -372,10 +510,19 @@ label em {
.site-rule-override-section .site-autohide-container, .site-rule-override-section .site-autohide-container,
.site-rule-override-section .site-playback-container, .site-rule-override-section .site-playback-container,
.site-rule-override-section .site-opacity-container, .site-rule-override-section .site-opacity-container,
.site-rule-override-section .site-subtitleNudge-container { .site-rule-override-section .site-subtitleNudge-container,
.site-controlbar-container,
.site-popup-controlbar-container,
.site-shortcuts-container {
padding-left: 4px; padding-left: 4px;
} }
.site-override-disabled {
opacity: 0.48;
pointer-events: none;
user-select: none;
}
.cb-editor { .cb-editor {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -481,6 +628,221 @@ label em {
border: 1px solid var(--border); border: 1px solid var(--border);
font-size: 12px; font-size: 12px;
line-height: 1; line-height: 1;
color: var(--text);
}
.cb-icon svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.cb-icon.cb-icon-nudge-pair {
width: auto;
min-width: 0;
padding: 0 4px;
gap: 4px;
background: transparent;
border: none;
}
.cb-nudge-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
flex-shrink: 0;
color: #fff;
}
.cb-nudge-chip[data-nudge-state="on"] {
background: #4b9135;
border: 1px solid #6ec754;
}
.cb-nudge-chip[data-nudge-state="off"] {
background: #943e3e;
border: 1px solid #c06060;
}
.cb-nudge-chip .vsc-btn-icon svg,
.cb-nudge-chip svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.cb-nudge-sep {
font-size: 11px;
font-weight: 600;
opacity: 0.45;
color: var(--text);
flex-shrink: 0;
}
.row-lucide-pair select {
justify-self: end;
}
.row-lucide-search-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 12px 0;
}
.row-lucide-search-row .lucide-search-label {
font-weight: 600;
font-size: 13px;
color: var(--text);
}
.lucide-search-field {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
max-width: 100%;
min-height: 44px;
padding: 0 14px 0 12px;
border: 1px solid var(--border-strong);
border-radius: 12px;
background: var(--panel);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.lucide-search-field:focus-within {
border-color: #9ca3af;
box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.08);
}
.lucide-search-icon {
display: flex;
color: var(--muted);
flex-shrink: 0;
}
.lucide-search-input {
flex: 1;
min-width: 0;
min-height: 40px;
padding: 8px 0;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
font-size: 14px;
}
.lucide-search-input::placeholder {
color: var(--muted);
opacity: 0.85;
}
.lucide-search-input:focus {
outline: none;
}
.lucide-icon-results {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 220px;
overflow-y: auto;
padding: 12px 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
button.lucide-result-tile {
width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: var(--panel-subtle);
border: 1px solid var(--border-strong);
cursor: pointer;
}
button.lucide-result-tile:hover {
background: var(--panel);
border-color: #9ca3af;
}
button.lucide-result-tile .lucide-result-thumb {
width: 22px;
height: 22px;
object-fit: contain;
pointer-events: none;
}
button.lucide-result-tile.lucide-picked {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(17, 24, 39, 0.12);
background: var(--panel);
}
.lucide-icon-status {
margin: 8px 0 0;
font-size: 12px;
color: var(--muted);
min-height: 1.2em;
}
.lucide-icon-preview-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 16px;
align-items: start;
margin-top: 14px;
}
.lucide-icon-preview {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-strong);
border-radius: 10px;
background: var(--panel-subtle);
color: var(--text);
}
.lucide-icon-preview svg {
width: 36px;
height: 36px;
}
.lucide-icon-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.lucide-icon-actions .lucide-apply {
background: #ffffff !important;
color: #111827 !important;
border: 1px solid var(--border-strong) !important;
font-weight: 600;
}
.lucide-icon-actions .lucide-apply:hover {
background: #f3f4f6 !important;
border-color: #9ca3af !important;
}
.lucide-icon-actions .secondary {
background: var(--panel-subtle);
color: var(--text);
border-color: var(--border-strong);
} }
.cb-label { .cb-label {
@@ -525,24 +887,61 @@ label em {
.site-rule-option { .site-rule-option {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 150px; grid-template-columns: minmax(0, 1fr) 160px;
gap: 16px; gap: 16px;
align-items: start; align-items: start;
padding: 8px 0; padding: 8px 0;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.site-rule-option-checkbox {
grid-template-columns: minmax(0, 1fr) auto;
}
.site-rule-option-checkbox > input[type="checkbox"] {
justify-self: end;
}
.site-rule-option > input[type="text"],
.site-rule-option > select {
justify-self: end;
text-align: right;
}
.site-rule-option.site-rule-margin-option .controller-margin-inputs {
justify-self: end;
}
.site-rule-body > .site-rule-option:first-child, .site-rule-body > .site-rule-option:first-child,
.site-rule-content > .site-rule-option:first-child { .site-rule-content > .site-rule-option:first-child {
padding-top: 0; padding-top: 0;
border-top: 0; border-top: 0;
} }
.site-rule-option label { .site-rule-option > label:not(.site-rule-split-label) {
display: flex; display: block;
margin: 0;
}
.site-rule-split-label {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: flex-start; align-items: flex-start;
gap: 10px; width: 100%;
width: auto; margin: 0;
cursor: pointer;
font-weight: 500;
color: var(--text);
}
.site-rule-split-label input[type="checkbox"] {
justify-self: end;
margin-top: 0;
}
.site-rule-option-checkbox > .site-rule-split-label {
grid-column: 1 / -1;
} }
.site-rule-controlbar, .site-rule-controlbar,
@@ -552,12 +951,8 @@ label em {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.site-rule-controlbar > label, .site-rule-controlbar > label.site-override-lead,
.site-rule-shortcuts > label { .site-rule-shortcuts > label.site-override-lead {
display: flex;
align-items: flex-start;
gap: 10px;
width: auto;
margin: 0; margin: 0;
} }
@@ -586,8 +981,8 @@ label em {
.force-label { .force-label {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 8px; gap: 10px;
width: auto; width: auto;
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
@@ -595,7 +990,7 @@ label em {
} }
.force-label input { .force-label input {
margin-top: 2px; margin-top: 0;
} }
.action-row { .action-row {
@@ -615,13 +1010,6 @@ label em {
display: none; display: none;
} }
#faq hr {
height: 1px;
margin: 0 0 14px;
border: 0;
background: var(--border);
}
.support-footer { .support-footer {
padding: 16px 20px; padding: 16px 20px;
color: var(--muted); color: var(--muted);
@@ -636,14 +1024,33 @@ label em {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.lucide-icon-preview-row {
grid-template-columns: 1fr;
}
.shortcut-row, .shortcut-row,
.shortcut-row.customs, .shortcut-row.customs,
.row, .row,
.row.row-checkbox,
.site-rule-option, .site-rule-option,
.site-shortcuts-container .shortcut-row { .site-shortcuts-container .shortcut-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.row input[type="text"],
.row select {
justify-self: stretch;
}
.site-rule-option > input[type="text"],
.site-rule-option > select {
justify-self: stretch;
}
.site-override-lead {
grid-template-columns: minmax(0, 1fr) auto;
}
.action-row button, .action-row button,
#addShortcutSelector { #addShortcutSelector {
width: 100%; width: 100%;
@@ -671,6 +1078,10 @@ label em {
padding: 16px; padding: 16px;
} }
.control-bars-group {
padding: 16px;
}
.site-rule-header { .site-rule-header {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -686,6 +1097,17 @@ label em {
--text: #f2f4f6; --text: #f2f4f6;
--muted: #a0a8b2; --muted: #a0a8b2;
--accent: #f2f4f6; --accent: #f2f4f6;
--switch-track-off: #374151;
--switch-track-off-border: #4b5563;
--switch-track-on: #aab7c6;
--switch-track-on-border: #aab7c6;
--switch-thumb-off: #f8fafc;
--switch-thumb-on: #111315;
--toggle-open-fg: #f2f4f6;
--toggle-open-bg: #2b3138;
--toggle-open-border: #4b5563;
--toggle-open-hover-bg: #374151;
--toggle-open-hover-border: #64748b;
--danger: #ff8a80; --danger: #ff8a80;
} }
@@ -719,4 +1141,25 @@ label em {
textarea:focus { textarea:focus {
border-color: #6b7280; border-color: #6b7280;
} }
.lucide-search-field:focus-within {
border-color: #6b7280;
box-shadow: 0 0 0 3px rgba(242, 244, 246, 0.12);
}
.lucide-icon-actions .lucide-apply {
background: #ffffff !important;
color: #111315 !important;
border-color: #e5e7eb !important;
}
.lucide-icon-actions .lucide-apply:hover {
background: #f3f4f6 !important;
border-color: #d1d5db !important;
}
button.lucide-result-tile .lucide-result-thumb {
filter: brightness(0) invert(1);
opacity: 0.92;
}
} }
+204 -95
View File
@@ -5,7 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Speeder Settings</title> <title>Speeder Settings</title>
<link rel="stylesheet" href="options.css" /> <link rel="stylesheet" href="options.css" />
<script src="shared/controller-utils.js"></script>
<script src="shared/key-bindings.js"></script>
<script src="shared/popup-controls.js"></script>
<script src="ui-icons.js"></script>
<script src="lucide-client.js"></script>
<script src="options.js"></script> <script src="options.js"></script>
<script src="shared/import-export.js"></script>
<script src="importExport.js"></script> <script src="importExport.js"></script>
</head> </head>
<body> <body>
@@ -180,11 +186,11 @@
<h4 class="defaults-sub-heading">General</h4> <h4 class="defaults-sub-heading">General</h4>
<div class="row"> <div class="row row-checkbox">
<label for="enabled">Enable</label> <label for="enabled">Enable</label>
<input id="enabled" type="checkbox" /> <input id="enabled" type="checkbox" />
</div> </div>
<div class="row"> <div class="row row-checkbox">
<label for="audioBoolean">Work on audio</label> <label for="audioBoolean">Work on audio</label>
<input id="audioBoolean" type="checkbox" /> <input id="audioBoolean" type="checkbox" />
</div> </div>
@@ -192,11 +198,11 @@
<div class="defaults-divider"></div> <div class="defaults-divider"></div>
<h4 class="defaults-sub-heading">Playback</h4> <h4 class="defaults-sub-heading">Playback</h4>
<div class="row"> <div class="row row-checkbox">
<label for="rememberSpeed">Remember playback speed</label> <label for="rememberSpeed">Remember playback speed</label>
<input id="rememberSpeed" type="checkbox" /> <input id="rememberSpeed" type="checkbox" />
</div> </div>
<div class="row"> <div class="row row-checkbox">
<label for="forceLastSavedSpeed" <label for="forceLastSavedSpeed"
>Force last saved speed<br /> >Force last saved speed<br />
<em <em
@@ -210,7 +216,7 @@
<div class="defaults-divider"></div> <div class="defaults-divider"></div>
<h4 class="defaults-sub-heading">Controller</h4> <h4 class="defaults-sub-heading">Controller</h4>
<div class="row"> <div class="row row-checkbox">
<label for="startHidden">Hide controller by default</label> <label for="startHidden">Hide controller by default</label>
<input id="startHidden" type="checkbox" /> <input id="startHidden" type="checkbox" />
</div> </div>
@@ -250,7 +256,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row row-checkbox">
<label for="hideWithControls" <label for="hideWithControls"
>Hide with controls<br /> >Hide with controls<br />
<em <em
@@ -270,15 +276,10 @@
</label> </label>
<input id="hideWithControlsTimer" type="text" placeholder="2" /> <input id="hideWithControlsTimer" type="text" placeholder="2" />
</div> </div>
<div class="row">
<label for="showPopupControlBar">Show popup control bar</label>
<input id="showPopupControlBar" type="checkbox" />
</div>
<div class="defaults-divider"></div> <div class="defaults-divider"></div>
<h4 class="defaults-sub-heading">Subtitle sync</h4> <h4 class="defaults-sub-heading">Subtitle sync</h4>
<div class="row"> <div class="row row-checkbox">
<label for="enableSubtitleNudge" <label for="enableSubtitleNudge"
>Enable subtitle nudge<br /><em >Enable subtitle nudge<br /><em
>Makes tiny playback changes to help keep subtitles aligned.</em >Makes tiny playback changes to help keep subtitles aligned.</em
@@ -302,58 +303,160 @@
</div> </div>
</section> </section>
<section id="controlBarSettings" class="settings-card"> <section
<div class="section-heading"> id="controlBarsGroup"
<h3>Control bar</h3> class="settings-card control-bars-group"
aria-labelledby="controlBarsGroupHeading"
>
<div class="section-heading section-heading-major">
<h2 id="controlBarsGroupHeading">Control bars</h2>
<p class="section-intro"> <p class="section-intro">
Drag blocks to reorder. Move between Active and Available to show In-page hover bar, extension popup bar, and Lucide icons for
or hide buttons. buttons.
</p> </p>
</div> </div>
<div class="cb-editor"> <div class="control-bars-inner">
<div class="cb-zone"> <section id="controlBarSettings" class="settings-card settings-card-nested">
<div class="cb-zone-label">Active</div> <div class="section-heading">
<div <h3>Hover control bar</h3>
id="controlBarActive" <p class="section-intro">
class="cb-dropzone cb-active-zone" Drag blocks to reorder. Move between Active and Available to
></div> show or hide buttons.
</div> </p>
<div class="cb-zone"> </div>
<div class="cb-zone-label">Available</div> <div class="cb-editor">
<div <div class="cb-zone">
id="controlBarAvailable" <div class="cb-zone-label">Active</div>
class="cb-dropzone cb-available-zone" <div
></div> id="controlBarActive"
</div> class="cb-dropzone cb-active-zone"
</div> ></div>
</section> </div>
<div class="cb-zone">
<div class="cb-zone-label">Available</div>
<div
id="controlBarAvailable"
class="cb-dropzone cb-available-zone"
></div>
</div>
</div>
</section>
<section id="popupControlBarSettings" class="settings-card"> <section id="popupControlBarSettings" class="settings-card settings-card-nested">
<div class="section-heading"> <div class="section-heading">
<h3>Popup control bar</h3> <h3>Popup control bar</h3>
<p class="section-intro"> <p class="section-intro">
Configure which buttons appear in the browser popup control bar. Configure which buttons appear in the browser popup control bar.
</p> </p>
</div> </div>
<div class="row"> <div class="row row-checkbox">
<label for="popupMatchHoverControls">Match hover controls</label> <label for="showPopupControlBar">Show popup control bar</label>
<input id="popupMatchHoverControls" type="checkbox" /> <input id="showPopupControlBar" type="checkbox" />
</div> </div>
<div id="popupCbEditorWrap" class="cb-editor cb-editor-disabled"> <div class="row row-checkbox">
<div class="cb-zone"> <label for="popupMatchHoverControls">Match hover controls</label>
<div class="cb-zone-label">Active</div> <input id="popupMatchHoverControls" type="checkbox" />
</div>
<div id="popupCbEditorWrap" class="cb-editor cb-editor-disabled">
<div class="cb-zone">
<div class="cb-zone-label">Active</div>
<div
id="popupControlBarActive"
class="cb-dropzone cb-active-zone"
></div>
</div>
<div class="cb-zone">
<div class="cb-zone-label">Available</div>
<div
id="popupControlBarAvailable"
class="cb-dropzone cb-available-zone"
></div>
</div>
</div>
</section>
<section id="lucideIconSettings" class="settings-card settings-card-nested">
<div class="section-heading">
<h3>Button icons (Lucide)</h3>
<p class="section-intro">
Search icons from the
<a
href="https://lucide.dev"
target="_blank"
rel="noopener noreferrer"
>Lucide</a
>
set (fetched from jsDelivr). Custom icons are cached in local
storage and included when you export settings. Subtitle nudge
icons use two menu entries (enabled and disabled), not the bar
block id
<code>nudge</code>.
</p>
</div>
<div class="row row-lucide-pair">
<label for="lucideIconActionSelect">Controller action</label>
<select id="lucideIconActionSelect"></select>
</div>
<div class="row row-lucide-search-row">
<label for="lucideIconSearch" class="lucide-search-label"
>Search icons</label
>
<div class="lucide-search-field">
<span class="lucide-search-icon" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</span>
<input
type="search"
id="lucideIconSearch"
class="lucide-search-input"
placeholder="Search by name or tag (e.g. star, arrow, media)…"
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<div <div
id="popupControlBarActive" id="lucideIconResults"
class="cb-dropzone cb-active-zone" class="lucide-icon-results"
role="listbox"
aria-label="Matching Lucide icons"
></div> ></div>
</div> <p id="lucideIconStatus" class="lucide-icon-status" aria-live="polite"></p>
<div class="cb-zone"> <div class="lucide-icon-preview-row">
<div class="cb-zone-label">Available</div> <div
<div id="lucideIconPreview"
id="popupControlBarAvailable" class="lucide-icon-preview"
class="cb-dropzone cb-available-zone" aria-live="polite"
></div> ></div>
</div> <div class="lucide-icon-actions">
<button type="button" id="lucideIconApply" class="lucide-apply">
Apply to action
</button>
<button type="button" id="lucideIconClearAction" class="secondary">
Clear this action
</button>
<button type="button" id="lucideIconClearAll" class="secondary">
Clear all custom icons
</button>
<button type="button" id="lucideIconReloadTags" class="secondary">
Refresh icon list from network
</button>
</div>
</div>
</section>
</div> </div>
</section> </section>
@@ -380,7 +483,15 @@
<template id="siteRuleTemplate"> <template id="siteRuleTemplate">
<div class="site-rule"> <div class="site-rule">
<div class="site-rule-header"> <div class="site-rule-header">
<button type="button" class="toggle-site-rule" title="Expand/Collapse">&plus;</button> <button
type="button"
class="toggle-site-rule"
title="Expand site rule"
aria-label="Expand site rule"
aria-expanded="false"
>
<span class="site-rule-toggle-icon" aria-hidden="true">&hellip;</span>
</button>
<input <input
type="text" type="text"
class="site-pattern" class="site-pattern"
@@ -389,20 +500,20 @@
<button type="button" class="remove-site-rule">Remove</button> <button type="button" class="remove-site-rule">Remove</button>
</div> </div>
<div class="site-rule-body"> <div class="site-rule-body">
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-checkbox">
<label> <label class="site-rule-split-label">
<span>Enable Speeder on this site</span>
<input type="checkbox" class="site-enabled" /> <input type="checkbox" class="site-enabled" />
Enable Speeder on this site
</label> </label>
</div> </div>
<div class="site-rule-content"> <div class="site-rule-content">
<div class="site-rule-override-section"> <div class="site-rule-override-section">
<label class="site-override-lead"> <label class="site-override-lead">
<span>Override placement for this site</span>
<input type="checkbox" class="override-placement" /> <input type="checkbox" class="override-placement" />
Override placement for this site
</label> </label>
<div class="site-placement-container" style="display: none"> <div class="site-placement-container">
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-field">
<label>Default controller location:</label> <label>Default controller location:</label>
<select class="site-controllerLocation"> <select class="site-controllerLocation">
<option value="top-left">Top left</option> <option value="top-left">Top left</option>
@@ -436,11 +547,11 @@
</div> </div>
<div class="site-rule-override-section"> <div class="site-rule-override-section">
<label class="site-override-lead"> <label class="site-override-lead">
<span>Override hide-by-default for this site</span>
<input type="checkbox" class="override-visibility" /> <input type="checkbox" class="override-visibility" />
Override hide-by-default for this site
</label> </label>
<div class="site-visibility-container" style="display: none"> <div class="site-visibility-container">
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-checkbox">
<label>Hide controller by default:</label> <label>Hide controller by default:</label>
<input type="checkbox" class="site-startHidden" /> <input type="checkbox" class="site-startHidden" />
</div> </div>
@@ -448,17 +559,17 @@
</div> </div>
<div class="site-rule-override-section"> <div class="site-rule-override-section">
<label class="site-override-lead"> <label class="site-override-lead">
<span>Override auto-hide for this site</span>
<input type="checkbox" class="override-autohide" /> <input type="checkbox" class="override-autohide" />
Override auto-hide for this site
</label> </label>
<div class="site-autohide-container" style="display: none"> <div class="site-autohide-container">
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-checkbox">
<label> <label class="site-rule-split-label">
<span>Hide with controls (idle-based)</span>
<input type="checkbox" class="site-hideWithControls" /> <input type="checkbox" class="site-hideWithControls" />
Hide with controls (idle-based)
</label> </label>
</div> </div>
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-field">
<label>Auto-hide timer (0.1&ndash;15s):</label> <label>Auto-hide timer (0.1&ndash;15s):</label>
<input type="text" class="site-hideWithControlsTimer" /> <input type="text" class="site-hideWithControlsTimer" />
</div> </div>
@@ -466,19 +577,19 @@
</div> </div>
<div class="site-rule-override-section"> <div class="site-rule-override-section">
<label class="site-override-lead"> <label class="site-override-lead">
<span>Override playback for this site</span>
<input type="checkbox" class="override-playback" /> <input type="checkbox" class="override-playback" />
Override playback for this site
</label> </label>
<div class="site-playback-container" style="display: none"> <div class="site-playback-container">
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-checkbox">
<label>Remember playback speed:</label> <label>Remember playback speed:</label>
<input type="checkbox" class="site-rememberSpeed" /> <input type="checkbox" class="site-rememberSpeed" />
</div> </div>
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-checkbox">
<label>Force last saved speed:</label> <label>Force last saved speed:</label>
<input type="checkbox" class="site-forceLastSavedSpeed" /> <input type="checkbox" class="site-forceLastSavedSpeed" />
</div> </div>
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-checkbox">
<label>Work on audio:</label> <label>Work on audio:</label>
<input type="checkbox" class="site-audioBoolean" /> <input type="checkbox" class="site-audioBoolean" />
</div> </div>
@@ -486,11 +597,11 @@
</div> </div>
<div class="site-rule-override-section"> <div class="site-rule-override-section">
<label class="site-override-lead"> <label class="site-override-lead">
<span>Override opacity for this site</span>
<input type="checkbox" class="override-opacity" /> <input type="checkbox" class="override-opacity" />
Override opacity for this site
</label> </label>
<div class="site-opacity-container" style="display: none"> <div class="site-opacity-container">
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-field">
<label>Controller opacity:</label> <label>Controller opacity:</label>
<input type="text" class="site-controllerOpacity" /> <input type="text" class="site-controllerOpacity" />
</div> </div>
@@ -498,15 +609,15 @@
</div> </div>
<div class="site-rule-override-section"> <div class="site-rule-override-section">
<label class="site-override-lead"> <label class="site-override-lead">
<span>Override subtitle nudge for this site</span>
<input type="checkbox" class="override-subtitleNudge" /> <input type="checkbox" class="override-subtitleNudge" />
Override subtitle nudge for this site
</label> </label>
<div class="site-subtitleNudge-container" style="display: none"> <div class="site-subtitleNudge-container">
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-checkbox">
<label>Enable subtitle nudge:</label> <label>Enable subtitle nudge:</label>
<input type="checkbox" class="site-enableSubtitleNudge" /> <input type="checkbox" class="site-enableSubtitleNudge" />
</div> </div>
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-field">
<label>Nudge interval (10&ndash;1000ms):</label> <label>Nudge interval (10&ndash;1000ms):</label>
<input type="text" class="site-subtitleNudgeInterval" placeholder="50" /> <input type="text" class="site-subtitleNudgeInterval" placeholder="50" />
</div> </div>
@@ -514,10 +625,10 @@
</div> </div>
<div class="site-rule-controlbar"> <div class="site-rule-controlbar">
<label class="site-override-lead"> <label class="site-override-lead">
<span>Override in-player control bar for this site</span>
<input type="checkbox" class="override-controlbar" /> <input type="checkbox" class="override-controlbar" />
Override in-player control bar for this site
</label> </label>
<div class="site-controlbar-container" style="display: none"> <div class="site-controlbar-container">
<div class="cb-editor"> <div class="cb-editor">
<div class="cb-zone"> <div class="cb-zone">
<div class="cb-zone-label">Active</div> <div class="cb-zone-label">Active</div>
@@ -532,11 +643,11 @@
</div> </div>
<div class="site-rule-controlbar"> <div class="site-rule-controlbar">
<label class="site-override-lead"> <label class="site-override-lead">
<span>Override extension popup for this site</span>
<input type="checkbox" class="override-popup-controlbar" /> <input type="checkbox" class="override-popup-controlbar" />
Override extension popup for this site
</label> </label>
<div class="site-popup-controlbar-container" style="display: none"> <div class="site-popup-controlbar-container">
<div class="site-rule-option"> <div class="site-rule-option site-rule-option-checkbox">
<label>Show popup control bar</label> <label>Show popup control bar</label>
<input type="checkbox" class="site-showPopupControlBar" /> <input type="checkbox" class="site-showPopupControlBar" />
</div> </div>
@@ -554,10 +665,10 @@
</div> </div>
<div class="site-rule-shortcuts"> <div class="site-rule-shortcuts">
<label class="site-override-lead"> <label class="site-override-lead">
<span>Override shortcuts for this site</span>
<input type="checkbox" class="override-shortcuts" /> <input type="checkbox" class="override-shortcuts" />
Override shortcuts for this site
</label> </label>
<div class="site-shortcuts-container" style="display: none"></div> <div class="site-shortcuts-container"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -580,8 +691,6 @@
</section> </section>
<section id="faq" class="settings-card info-card"> <section id="faq" class="settings-card info-card">
<hr />
<h4>Extension controls not appearing?</h4> <h4>Extension controls not appearing?</h4>
<p> <p>
This extension only works with HTML5 audio and video. If the This extension only works with HTML5 audio and video. If the
+690 -454
View File
@@ -1,106 +1,44 @@
var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var controllerUtils = speederShared.controllerUtils || {};
var keyBindingUtils = speederShared.keyBindings || {};
var popupControlUtils = speederShared.popupControls || {};
var keyBindings = []; var keyBindings = [];
var keyCodeAliases = { var bindingCodeAliases = {
0: "null", Space: "Space",
null: "null", ArrowLeft: "Left",
undefined: "null", ArrowUp: "Up",
32: "Space", ArrowRight: "Right",
37: "Left", ArrowDown: "Down",
38: "Up", Numpad0: "Num 0",
39: "Right", Numpad1: "Num 1",
40: "Down", Numpad2: "Num 2",
96: "Num 0", Numpad3: "Num 3",
97: "Num 1", Numpad4: "Num 4",
98: "Num 2", Numpad5: "Num 5",
99: "Num 3", Numpad6: "Num 6",
100: "Num 4", Numpad7: "Num 7",
101: "Num 5", Numpad8: "Num 8",
102: "Num 6", Numpad9: "Num 9",
103: "Num 7", NumpadMultiply: "Num *",
104: "Num 8", NumpadAdd: "Num +",
105: "Num 9", NumpadSubtract: "Num -",
106: "Num *", NumpadDecimal: "Num .",
107: "Num +", NumpadDivide: "Num /",
109: "Num -", Backquote: "`",
110: "Num .", Minus: "-",
111: "Num /", Equal: "=",
112: "F1", BracketLeft: "[",
113: "F2", BracketRight: "]",
114: "F3", Backslash: "\\",
115: "F4", Semicolon: ";",
116: "F5", Quote: "'",
117: "F6", Comma: ",",
118: "F7", Period: ".",
119: "F8", Slash: "/"
120: "F9",
121: "F10",
122: "F11",
123: "F12",
186: ";",
188: "<",
189: "-",
187: "+",
190: ">",
191: "/",
192: "~",
219: "[",
220: "\\",
221: "]",
222: "'",
59: ";",
61: "+",
173: "-"
};
var keyCodeToKey = {
32: " ",
37: "ArrowLeft",
38: "ArrowUp",
39: "ArrowRight",
40: "ArrowDown",
96: "0",
97: "1",
98: "2",
99: "3",
100: "4",
101: "5",
102: "6",
103: "7",
104: "8",
105: "9",
106: "*",
107: "+",
109: "-",
110: ".",
111: "/",
112: "F1",
113: "F2",
114: "F3",
115: "F4",
116: "F5",
117: "F6",
118: "F7",
119: "F8",
120: "F9",
121: "F10",
122: "F11",
123: "F12",
186: ";",
188: "<",
189: "-",
187: "+",
190: ">",
191: "/",
192: "~",
219: "[",
220: "\\",
221: "]",
222: "'",
59: ";",
61: "+",
173: "-"
}; };
var modifierKeys = new Set([ var modifierKeys = new Set([
@@ -114,23 +52,18 @@ var modifierKeys = new Set([
"Shift" "Shift"
]); ]);
var displayKeyAliases = { var controllerLocations = Array.isArray(controllerUtils.controllerLocations)
" ": "Space", ? controllerUtils.controllerLocations.slice()
ArrowLeft: "Left", : [
ArrowUp: "Up", "top-left",
ArrowRight: "Right", "top-center",
ArrowDown: "Down" "top-right",
}; "middle-right",
var controllerLocations = [ "bottom-right",
"top-left", "bottom-center",
"top-center", "bottom-left",
"top-right", "middle-left"
"middle-right", ];
"bottom-right",
"bottom-center",
"bottom-left",
"middle-left"
];
var controllerButtonDefs = { var controllerButtonDefs = {
rewind: { icon: "\u00AB", name: "Rewind" }, rewind: { icon: "\u00AB", name: "Rewind" },
@@ -138,7 +71,7 @@ var controllerButtonDefs = {
faster: { icon: "+", name: "Increase speed" }, faster: { icon: "+", name: "Increase speed" },
advance: { icon: "\u00BB", name: "Advance" }, advance: { icon: "\u00BB", name: "Advance" },
display: { icon: "\u00D7", name: "Close controller" }, display: { icon: "\u00D7", name: "Close controller" },
reset: { icon: "\u21BA", name: "Reset speed" }, reset: { icon: "\u21BB", name: "Reset speed" },
fast: { icon: "\u2605", name: "Preferred speed" }, fast: { icon: "\u2605", name: "Preferred speed" },
nudge: { icon: "\u2713", name: "Subtitle nudge" }, nudge: { icon: "\u2713", name: "Subtitle nudge" },
settings: { icon: "\u2699", name: "Settings" }, settings: { icon: "\u2699", name: "Settings" },
@@ -147,12 +80,80 @@ var controllerButtonDefs = {
mark: { icon: "\u2691", name: "Set marker" }, mark: { icon: "\u2691", name: "Set marker" },
jump: { icon: "\u21E5", name: "Jump to marker" } jump: { icon: "\u21E5", name: "Jump to marker" }
}; };
var popupExcludedButtonIds = new Set(["settings"]);
function createDefaultBinding(action, key, keyCode, value) { /** Lucide picker only — not control-bar blocks (chip uses subtitleNudgeOn/Off). */
var lucideSubtitleNudgeActionLabels = {
subtitleNudgeOn: "Subtitle nudge — enabled",
subtitleNudgeOff: "Subtitle nudge — disabled"
};
function sanitizePopupButtonOrder(buttonIds) {
return popupControlUtils.sanitizeButtonOrder(
buttonIds,
controllerButtonDefs,
popupExcludedButtonIds
);
}
/** Cached custom Lucide SVGs (mirrors chrome.storage.local customButtonIcons). */
var customButtonIconsLive = {};
function fillControlBarIconElement(icon, buttonId) {
if (!icon || !buttonId) return;
var doc = icon.ownerDocument || document;
if (buttonId === "nudge") {
vscClearElement(icon);
icon.className = "cb-icon cb-icon-nudge-pair";
function nudgeChipMarkup(action) {
var c = customButtonIconsLive[action];
if (c && c.svg) return c.svg;
if (typeof vscIconSvgString === "function") {
return vscIconSvgString(action, 14) || "";
}
return "";
}
function appendChip(action, stateKey) {
var sp = document.createElement("span");
sp.className = "cb-nudge-chip";
sp.setAttribute("data-nudge-state", stateKey);
var inner = nudgeChipMarkup(action);
if (inner) {
var wrap = vscCreateSvgWrap(doc, inner, "vsc-btn-icon");
if (wrap) {
sp.appendChild(wrap);
}
}
icon.appendChild(sp);
}
appendChip("subtitleNudgeOn", "on");
var sep = document.createElement("span");
sep.className = "cb-nudge-sep";
sep.textContent = "/";
icon.appendChild(sep);
appendChip("subtitleNudgeOff", "off");
return;
}
icon.className = "cb-icon";
var custom = customButtonIconsLive[buttonId];
if (custom && custom.svg) {
if (vscSetSvgContent(icon, custom.svg)) return;
}
if (typeof vscIconSvgString === "function") {
var svgHtml = vscIconSvgString(buttonId, 16);
if (svgHtml) {
if (vscSetSvgContent(icon, svgHtml)) return;
}
}
vscClearElement(icon);
var def = controllerButtonDefs[buttonId];
icon.textContent = (def && def.icon) || "?";
}
function createDefaultBinding(action, code, value) {
return { return {
action: action, action: action,
key: key, code: code,
keyCode: keyCode,
value: value, value: value,
force: false, force: false,
predefined: true predefined: true
@@ -162,7 +163,6 @@ function createDefaultBinding(action, key, keyCode, value) {
var tcDefaults = { var tcDefaults = {
speed: 1.0, speed: 1.0,
lastSpeed: 1.0, lastSpeed: 1.0,
displayKeyCode: 86,
rememberSpeed: false, rememberSpeed: false,
audioBoolean: false, audioBoolean: false,
startHidden: false, startHidden: false,
@@ -178,15 +178,15 @@ var tcDefaults = {
controllerMarginBottom: 65, controllerMarginBottom: 65,
controllerMarginLeft: 0, controllerMarginLeft: 0,
keyBindings: [ keyBindings: [
createDefaultBinding("display", "V", 86, 0), createDefaultBinding("display", "KeyV", 0),
createDefaultBinding("move", "P", 80, 0), createDefaultBinding("move", "KeyP", 0),
createDefaultBinding("slower", "S", 83, 0.1), createDefaultBinding("slower", "KeyS", 0.1),
createDefaultBinding("faster", "D", 68, 0.1), createDefaultBinding("faster", "KeyD", 0.1),
createDefaultBinding("rewind", "Z", 90, 10), createDefaultBinding("rewind", "KeyZ", 10),
createDefaultBinding("advance", "X", 88, 10), createDefaultBinding("advance", "KeyX", 10),
createDefaultBinding("reset", "R", 82, 1), createDefaultBinding("reset", "KeyR", 1),
createDefaultBinding("fast", "G", 71, 1.8), createDefaultBinding("fast", "KeyG", 1.8),
createDefaultBinding("toggleSubtitleNudge", "N", 78, 0) createDefaultBinding("toggleSubtitleNudge", "KeyN", 0)
], ],
siteRules: [ siteRules: [
{ {
@@ -198,6 +198,7 @@ var tcDefaults = {
{ {
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/", pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true, enabled: true,
rememberSpeed: true,
controllerMarginTop: 60, controllerMarginTop: 60,
controllerMarginBottom: 85 controllerMarginBottom: 85
} }
@@ -227,6 +228,20 @@ const actionLabels = {
toggleSubtitleNudge: "Toggle subtitle nudge" toggleSubtitleNudge: "Toggle subtitle nudge"
}; };
const speedBindingActions = ["slower", "faster", "fast"];
const requiredShortcutActions = new Set(["display", "slower", "faster"]);
function formatSpeedBindingDisplay(action, value) {
if (!speedBindingActions.includes(action)) {
return value;
}
var n = Number(value);
if (!isFinite(n)) {
return value;
}
return n.toFixed(2);
}
const customActionsNoValues = [ const customActionsNoValues = [
"reset", "reset",
"display", "display",
@@ -276,21 +291,91 @@ function refreshAddShortcutSelector() {
} }
} }
function ensureDefaultBinding(storage, action, key, keyCode, value) { function ensureDefaultBinding(storage, action, code, value) {
if (storage.keyBindings.some((item) => item.action === action)) return; if (storage.keyBindings.some((item) => item.action === action)) return;
storage.keyBindings.push(createDefaultBinding(action, key, keyCode, value)); storage.keyBindings.push(createDefaultBinding(action, code, value));
} }
function normalizeControllerLocation(location) { function normalizeControllerLocation(location) {
if (controllerLocations.includes(location)) return location; return controllerUtils.normalizeControllerLocation(
return tcDefaults.controllerLocation; location,
tcDefaults.controllerLocation
);
} }
function clampMarginPxInput(el, fallback) { function clampMarginPxInput(el, fallback) {
var n = parseInt(el && el.value, 10); return controllerUtils.clampControllerMarginPx(el && el.value, fallback);
if (!Number.isFinite(n)) return fallback; }
return Math.min(200, Math.max(0, n));
function parseFiniteNumberOrFallback(value, fallback) {
var numericValue = parseFloat(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function updateSiteRuleToggleIcon(toggleButton, action) {
if (!toggleButton) return;
var iconEl = toggleButton.querySelector(".site-rule-toggle-icon");
if (!iconEl) return;
if (typeof vscIconSvgString === "function" && typeof vscSetSvgContent === "function") {
var svgHtml = vscIconSvgString(action, 16);
if (svgHtml && vscSetSvgContent(iconEl, svgHtml)) {
return;
}
}
iconEl.textContent = action === "chevronUp" ? "\u2212" : "\u2026";
}
function setSiteRuleExpandedState(ruleEl, expanded) {
if (!ruleEl) return;
var ruleBody = ruleEl.querySelector(".site-rule-body");
var toggleButton = ruleEl.querySelector(".toggle-site-rule");
if (ruleBody) {
ruleBody.style.display = expanded ? "block" : "none";
}
ruleEl.classList.toggle("collapsed", !expanded);
if (!toggleButton) return;
var label = expanded ? "Collapse site rule" : "Expand site rule";
toggleButton.title = label;
toggleButton.setAttribute("aria-label", label);
toggleButton.setAttribute("aria-expanded", expanded ? "true" : "false");
updateSiteRuleToggleIcon(toggleButton, expanded ? "chevronUp" : "moreHorizontal");
}
function setSiteOverrideContainerState(container, enabled) {
if (!container) return;
container.classList.toggle("site-override-disabled", !enabled);
container.setAttribute("aria-disabled", enabled ? "false" : "true");
Array.prototype.forEach.call(
container.querySelectorAll("input, select, textarea, button"),
function (control) {
control.disabled = !enabled;
}
);
Array.prototype.forEach.call(
container.querySelectorAll(".cb-block"),
function (block) {
block.draggable = enabled;
}
);
}
function applySiteRuleOverrideState(ruleEl, checkboxClass, containerClass) {
if (!ruleEl) return;
var checkbox = ruleEl.querySelector("." + checkboxClass);
var container = ruleEl.querySelector("." + containerClass);
if (!container) return;
container.style.display = "block";
setSiteOverrideContainerState(container, checkbox ? checkbox.checked : false);
} }
function syncSiteRuleField(ruleEl, rule, key, isCheckbox) { function syncSiteRuleField(ruleEl, rule, key, isCheckbox) {
@@ -310,128 +395,88 @@ function syncSiteRuleField(ruleEl, rule, key, isCheckbox) {
} }
function normalizeBindingKey(key) { function normalizeBindingKey(key) {
if (typeof key !== "string" || key.length === 0) return null; return keyBindingUtils.normalizeBindingKey(key);
if (key === "Spacebar") return " ";
if (key === "Esc") return "Escape";
if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase();
return key;
} }
function getLegacyKeyCode(binding) { function getLegacyKeyCode(binding) {
if (!binding) return null; return keyBindingUtils.getLegacyKeyCode(binding);
if (Number.isInteger(binding.keyCode)) return binding.keyCode;
if (typeof binding.key === "number" && Number.isInteger(binding.key)) {
return binding.key;
}
return null;
} }
function legacyKeyCodeToBinding(keyCode) { function legacyBindingKeyToCode(key) {
if (!Number.isInteger(keyCode)) return null; return keyBindingUtils.legacyBindingKeyToCode(key);
var normalizedKey = keyCodeToKey[keyCode]; }
if (!normalizedKey && keyCode >= 48 && keyCode <= 57) {
normalizedKey = String.fromCharCode(keyCode); function legacyKeyCodeToCode(keyCode) {
} return keyBindingUtils.legacyKeyCodeToCode(keyCode);
if (!normalizedKey && keyCode >= 65 && keyCode <= 90) { }
normalizedKey = String.fromCharCode(keyCode);
} function inferBindingCode(binding, fallbackCode) {
return { return keyBindingUtils.inferBindingCode(binding, fallbackCode);
key: normalizeBindingKey(normalizedKey),
keyCode: keyCode,
code: null,
disabled: false
};
} }
function createDisabledBinding() { function createDisabledBinding() {
return { return {
key: null,
keyCode: null,
code: null, code: null,
disabled: true disabled: true
}; };
} }
function normalizeStoredBinding(binding, fallbackKeyCode) { function normalizeStoredBinding(binding, fallbackCode) {
var fallbackBinding = legacyKeyCodeToBinding(fallbackKeyCode);
if (!binding) { if (!binding) {
return fallbackBinding; if (!fallbackCode) return null;
return {
code: fallbackCode,
disabled: false
};
} }
if ( if (
binding.disabled === true || binding.disabled === true ||
(binding.key === null && (binding.code === null &&
binding.keyCode === null && binding.key === null &&
binding.code === null) binding.keyCode === null)
) { ) {
return createDisabledBinding(); return createDisabledBinding();
} }
var normalized = { var normalizedCode = inferBindingCode(binding, fallbackCode);
key: null, if (!normalizedCode) {
keyCode: null,
code:
typeof binding.code === "string" && binding.code.length > 0
? binding.code
: null,
disabled: false
};
if (typeof binding.key === "string") {
normalized.key = normalizeBindingKey(binding.key);
}
var legacyKeyCode = getLegacyKeyCode(binding);
if (Number.isInteger(legacyKeyCode)) {
var legacyBinding = legacyKeyCodeToBinding(legacyKeyCode);
if (legacyBinding) {
normalized.key = normalized.key || legacyBinding.key;
normalized.keyCode = legacyKeyCode;
}
}
if (Number.isInteger(binding.keyCode)) {
normalized.keyCode = binding.keyCode;
}
if (!normalized.key && fallbackBinding) {
normalized.key = fallbackBinding.key;
if (normalized.keyCode === null) normalized.keyCode = fallbackBinding.keyCode;
}
if (!normalized.key && !normalized.code && normalized.keyCode === null) {
return null; return null;
} }
var normalized = {
code: normalizedCode,
disabled: false
};
return normalized; return normalized;
} }
function formatBindingCode(code) {
if (typeof code !== "string" || code.length === 0) return "";
if (bindingCodeAliases[code]) return bindingCodeAliases[code];
if (/^Key[A-Z]$/.test(code)) return code.substring(3);
if (/^Digit[0-9]$/.test(code)) return code.substring(5);
if (/^F([1-9]|1[0-2])$/.test(code)) return code;
return code;
}
function getBindingLabel(binding) { function getBindingLabel(binding) {
if (!binding) return ""; if (!binding) return "";
if (binding.disabled) return ""; if (binding.disabled) return "";
if (binding.key) { return formatBindingCode(binding.code);
return displayKeyAliases[binding.key] || binding.key;
}
var legacyKeyCode = getLegacyKeyCode(binding);
if (keyCodeAliases[legacyKeyCode]) return keyCodeAliases[legacyKeyCode];
if (Number.isInteger(legacyKeyCode)) return String.fromCharCode(legacyKeyCode);
return "";
} }
function setShortcutInputBinding(input, binding) { function setShortcutInputBinding(input, binding) {
input.vscBinding = binding ? Object.assign({}, binding) : null; input.vscBinding = binding ? Object.assign({}, binding) : null;
input.keyCode =
binding && Number.isInteger(binding.keyCode) ? binding.keyCode : null;
input.value = getBindingLabel(binding); input.value = getBindingLabel(binding);
} }
function captureBindingFromEvent(event) { function captureBindingFromEvent(event) {
var normalizedKey = normalizeBindingKey(event.key); if (modifierKeys.has(event.key)) return null;
if (!normalizedKey || modifierKeys.has(normalizedKey)) return null; if (typeof event.code !== "string" || event.code.length === 0) return null;
return { return {
key: normalizedKey, code: event.code,
keyCode: Number.isInteger(event.keyCode) ? event.keyCode : null,
code: event.code || null,
disabled: false disabled: false
}; };
} }
@@ -462,8 +507,10 @@ function recordKeyPress(event) {
} }
function inputFilterNumbersOnly(event) { function inputFilterNumbersOnly(event) {
var char = String.fromCharCode(event.keyCode); var char = event.key;
if ( if (
typeof char !== "string" ||
char.length !== 1 ||
!/[\d\.]$/.test(char) || !/[\d\.]$/.test(char) ||
!/^\d+(\.\d*)?$/.test(event.target.value + char) !/^\d+(\.\d*)?$/.test(event.target.value + char)
) { ) {
@@ -490,7 +537,15 @@ function updateCustomShortcutInputText(inputItem, bindingOrKeyCode) {
return; return;
} }
setShortcutInputBinding(inputItem, legacyKeyCodeToBinding(bindingOrKeyCode)); if (typeof bindingOrKeyCode === "string") {
setShortcutInputBinding(inputItem, { code: bindingOrKeyCode, disabled: false });
return;
}
setShortcutInputBinding(
inputItem,
normalizeStoredBinding({ keyCode: bindingOrKeyCode })
);
} }
function appendSelectOptions(select, options) { function appendSelectOptions(select, options) {
@@ -526,7 +581,7 @@ function add_shortcut(action, value) {
valueInput.value = "N/A"; valueInput.value = "N/A";
valueInput.disabled = true; valueInput.disabled = true;
} else { } else {
valueInput.value = value || 0; valueInput.value = formatSpeedBindingDisplay(action, value || 0);
} }
var removeButton = document.createElement("button"); var removeButton = document.createElement("button");
@@ -550,23 +605,33 @@ function createKeyBindings(item) {
var input = item.querySelector(".customKey"); var input = item.querySelector(".customKey");
var valueInput = item.querySelector(".customValue"); var valueInput = item.querySelector(".customValue");
var predefined = !!item.id; var predefined = !!item.id;
var fallbackKeyCode = var binding = normalizeStoredBinding(input.vscBinding);
predefined && action === "display"
? tcDefaults.displayKeyCode
: undefined;
var binding = normalizeStoredBinding(input.vscBinding, fallbackKeyCode);
if (!binding) { if (!binding) {
if (requiredShortcutActions.has(action)) {
return {
valid: false,
message:
"Error: Shortcut for " +
(actionLabels[action] || action) +
" cannot be empty. Unable to save"
};
}
binding = createDisabledBinding();
}
if (binding.disabled === true && requiredShortcutActions.has(action)) {
return { return {
valid: false, valid: false,
message: "Error: Shortcut for " + action + " is invalid. Unable to save" message:
"Error: Shortcut for " +
(actionLabels[action] || action) +
" cannot be empty. Unable to save"
}; };
} }
keyBindings.push({ keyBindings.push({
action: action, action: action,
key: binding.key,
keyCode: binding.keyCode,
code: binding.code, code: binding.code,
disabled: binding.disabled === true, disabled: binding.disabled === true,
value: customActionsNoValues.includes(action) value: customActionsNoValues.includes(action)
@@ -646,8 +711,10 @@ function save_options() {
document.getElementById("controllerLocation").value document.getElementById("controllerLocation").value
); );
settings.controllerOpacity = settings.controllerOpacity =
parseFloat(document.getElementById("controllerOpacity").value) || parseFiniteNumberOrFallback(
tcDefaults.controllerOpacity; document.getElementById("controllerOpacity").value,
tcDefaults.controllerOpacity
);
settings.controllerMarginTop = clampMarginPxInput( settings.controllerMarginTop = clampMarginPxInput(
document.getElementById("controllerMarginTop"), document.getElementById("controllerMarginTop"),
@@ -678,7 +745,7 @@ function save_options() {
document.getElementById("showPopupControlBar").checked; document.getElementById("showPopupControlBar").checked;
settings.popupMatchHoverControls = settings.popupMatchHoverControls =
document.getElementById("popupMatchHoverControls").checked; document.getElementById("popupMatchHoverControls").checked;
settings.popupControllerButtons = getPopupControlBarOrder(); settings.popupControllerButtons = sanitizePopupButtonOrder(getPopupControlBarOrder());
// Collect site rules // Collect site rules
settings.siteRules = []; settings.siteRules = [];
@@ -735,8 +802,10 @@ function save_options() {
if (ruleEl.querySelector(".override-opacity").checked) { if (ruleEl.querySelector(".override-opacity").checked) {
rule.controllerOpacity = rule.controllerOpacity =
parseFloat(ruleEl.querySelector(".site-controllerOpacity").value) || parseFiniteNumberOrFallback(
settings.controllerOpacity; ruleEl.querySelector(".site-controllerOpacity").value,
settings.controllerOpacity
);
} }
if (ruleEl.querySelector(".override-subtitleNudge").checked) { if (ruleEl.querySelector(".override-subtitleNudge").checked) {
@@ -767,33 +836,52 @@ function save_options() {
ruleEl.querySelector(".site-showPopupControlBar").checked; ruleEl.querySelector(".site-showPopupControlBar").checked;
var popupActiveZone = ruleEl.querySelector(".site-popup-cb-active"); var popupActiveZone = ruleEl.querySelector(".site-popup-cb-active");
if (popupActiveZone) { if (popupActiveZone) {
rule.popupControllerButtons = readControlBarOrder(popupActiveZone); rule.popupControllerButtons = sanitizePopupButtonOrder(
readControlBarOrder(popupActiveZone)
);
} }
} }
if (ruleEl.querySelector(".override-shortcuts").checked) { if (ruleEl.querySelector(".override-shortcuts").checked) {
var shortcuts = []; var shortcuts = [];
ruleEl.querySelectorAll(".site-shortcuts-container .customs").forEach((shortcutRow) => { ruleEl.querySelectorAll(".site-shortcuts-container .customs").forEach((shortcutRow) => {
if (saveError) return;
var action = shortcutRow.dataset.action; var action = shortcutRow.dataset.action;
var keyInput = shortcutRow.querySelector(".customKey"); var keyInput = shortcutRow.querySelector(".customKey");
var valueInput = shortcutRow.querySelector(".customValue"); var valueInput = shortcutRow.querySelector(".customValue");
var forceCheckbox = shortcutRow.querySelector(".customForce"); var forceCheckbox = shortcutRow.querySelector(".customForce");
var binding = normalizeStoredBinding(keyInput.vscBinding); var binding = normalizeStoredBinding(keyInput.vscBinding);
if (binding) { if (!binding) {
shortcuts.push({ if (requiredShortcutActions.has(action)) {
action: action, saveError =
key: binding.key, "Error: Site rule shortcut for " +
keyCode: binding.keyCode, (actionLabels[action] || action) +
code: binding.code, " cannot be empty. Unable to save";
disabled: binding.disabled === true, return;
value: customActionsNoValues.includes(action) }
? 0 binding = createDisabledBinding();
: Number(valueInput.value),
force: forceCheckbox ? forceCheckbox.checked : false
});
} }
if (binding.disabled === true && requiredShortcutActions.has(action)) {
saveError =
"Error: Site rule shortcut for " +
(actionLabels[action] || action) +
" cannot be empty. Unable to save";
return;
}
shortcuts.push({
action: action,
code: binding.code,
disabled: binding.disabled === true,
value: customActionsNoValues.includes(action)
? 0
: Number(valueInput.value),
force: forceCheckbox ? forceCheckbox.checked : false
});
}); });
if (saveError) return;
if (shortcuts.length > 0) rule.shortcuts = shortcuts; if (shortcuts.length > 0) rule.shortcuts = shortcuts;
} }
@@ -819,42 +907,10 @@ function save_options() {
function ensureAllDefaultBindings(storage) { function ensureAllDefaultBindings(storage) {
tcDefaults.keyBindings.forEach((binding) => { tcDefaults.keyBindings.forEach((binding) => {
// Special case for "display" to support legacy displayKeyCode ensureDefaultBinding(storage, binding.action, binding.code, binding.value);
if (binding.action === "display" && storage.displayKeyCode) {
ensureDefaultBinding(storage, "display", "V", storage.displayKeyCode, 0);
} else {
ensureDefaultBinding(
storage,
binding.action,
binding.key,
binding.keyCode,
binding.value
);
}
}); });
} }
function migrateLegacyBlacklist(storage) {
if (!storage.blacklist || typeof storage.blacklist !== "string") {
return [];
}
var siteRules = [];
var lines = storage.blacklist.split("\n");
lines.forEach((line) => {
var pattern = line.replace(regStrip, "");
if (pattern.length === 0) return;
siteRules.push({
pattern: pattern,
disableExtension: true
});
});
return siteRules;
}
function addSiteRuleShortcut(container, action, binding, value, force) { function addSiteRuleShortcut(container, action, binding, value, force) {
var div = document.createElement("div"); var div = document.createElement("div");
div.setAttribute("class", "shortcut-row customs"); div.setAttribute("class", "shortcut-row customs");
@@ -899,9 +955,11 @@ function addSiteRuleShortcut(container, action, binding, value, force) {
valueInput.className = "customValue"; valueInput.className = "customValue";
valueInput.type = "text"; valueInput.type = "text";
valueInput.placeholder = "value (0.10)"; valueInput.placeholder = "value (0.10)";
valueInput.value = value || 0;
if (customActionsNoValues.includes(action)) { if (customActionsNoValues.includes(action)) {
valueInput.value = "N/A";
valueInput.disabled = true; valueInput.disabled = true;
} else {
valueInput.value = formatSpeedBindingDisplay(action, value || 0);
} }
var forceLabel = document.createElement("label"); var forceLabel = document.createElement("label");
@@ -937,9 +995,7 @@ function createSiteRule(rule) {
ruleEl.querySelector(".site-pattern").value = pattern; ruleEl.querySelector(".site-pattern").value = pattern;
// Make the rule body collapsed by default // Make the rule body collapsed by default
var ruleBody = ruleEl.querySelector(".site-rule-body"); setSiteRuleExpandedState(ruleEl, false);
ruleBody.style.display = "none";
ruleEl.classList.add("collapsed");
var enabledCheckbox = ruleEl.querySelector(".site-enabled"); var enabledCheckbox = ruleEl.querySelector(".site-enabled");
var contentEl = ruleEl.querySelector(".site-rule-content"); var contentEl = ruleEl.querySelector(".site-rule-content");
@@ -974,108 +1030,99 @@ function createSiteRule(rule) {
]; ];
var hasPlacementOverride = var hasPlacementOverride =
rule && placementKeys.some(function (k) { return rule[k] !== undefined; }); rule && placementKeys.some(function (k) { return rule[k] !== undefined; });
if (hasPlacementOverride) { ruleEl.querySelector(".override-placement").checked = Boolean(hasPlacementOverride);
ruleEl.querySelector(".override-placement").checked = true;
ruleEl.querySelector(".site-placement-container").style.display = "block";
}
syncSiteRuleField(ruleEl, rule, "controllerLocation", false); syncSiteRuleField(ruleEl, rule, "controllerLocation", false);
syncSiteRuleField(ruleEl, rule, "controllerMarginTop", false); syncSiteRuleField(ruleEl, rule, "controllerMarginTop", false);
syncSiteRuleField(ruleEl, rule, "controllerMarginBottom", false); syncSiteRuleField(ruleEl, rule, "controllerMarginBottom", false);
applySiteRuleOverrideState(ruleEl, "override-placement", "site-placement-container");
if (rule && rule.startHidden !== undefined) { ruleEl.querySelector(".override-visibility").checked = Boolean(
ruleEl.querySelector(".override-visibility").checked = true; rule && rule.startHidden !== undefined
ruleEl.querySelector(".site-visibility-container").style.display = "block"; );
}
syncSiteRuleField(ruleEl, rule, "startHidden", true); syncSiteRuleField(ruleEl, rule, "startHidden", true);
applySiteRuleOverrideState(ruleEl, "override-visibility", "site-visibility-container");
if ( var hasAutohideOverride = Boolean(
rule && rule &&
(rule.hideWithControls !== undefined || (rule.hideWithControls !== undefined ||
rule.hideWithControlsTimer !== undefined) rule.hideWithControlsTimer !== undefined)
) { );
ruleEl.querySelector(".override-autohide").checked = true; ruleEl.querySelector(".override-autohide").checked = hasAutohideOverride;
ruleEl.querySelector(".site-autohide-container").style.display = "block";
}
syncSiteRuleField(ruleEl, rule, "hideWithControls", true); syncSiteRuleField(ruleEl, rule, "hideWithControls", true);
syncSiteRuleField(ruleEl, rule, "hideWithControlsTimer", false); syncSiteRuleField(ruleEl, rule, "hideWithControlsTimer", false);
applySiteRuleOverrideState(ruleEl, "override-autohide", "site-autohide-container");
if ( var hasPlaybackOverride = Boolean(
rule && rule &&
(rule.rememberSpeed !== undefined || (rule.rememberSpeed !== undefined ||
rule.forceLastSavedSpeed !== undefined || rule.forceLastSavedSpeed !== undefined ||
rule.audioBoolean !== undefined) rule.audioBoolean !== undefined)
) { );
ruleEl.querySelector(".override-playback").checked = true; ruleEl.querySelector(".override-playback").checked = hasPlaybackOverride;
ruleEl.querySelector(".site-playback-container").style.display = "block";
}
syncSiteRuleField(ruleEl, rule, "rememberSpeed", true); syncSiteRuleField(ruleEl, rule, "rememberSpeed", true);
syncSiteRuleField(ruleEl, rule, "forceLastSavedSpeed", true); syncSiteRuleField(ruleEl, rule, "forceLastSavedSpeed", true);
syncSiteRuleField(ruleEl, rule, "audioBoolean", true); syncSiteRuleField(ruleEl, rule, "audioBoolean", true);
applySiteRuleOverrideState(ruleEl, "override-playback", "site-playback-container");
if (rule && rule.controllerOpacity !== undefined) { ruleEl.querySelector(".override-opacity").checked = Boolean(
ruleEl.querySelector(".override-opacity").checked = true; rule && rule.controllerOpacity !== undefined
ruleEl.querySelector(".site-opacity-container").style.display = "block"; );
}
syncSiteRuleField(ruleEl, rule, "controllerOpacity", false); syncSiteRuleField(ruleEl, rule, "controllerOpacity", false);
applySiteRuleOverrideState(ruleEl, "override-opacity", "site-opacity-container");
if ( var hasSubtitleNudgeOverride = Boolean(
rule && rule &&
(rule.enableSubtitleNudge !== undefined || (rule.enableSubtitleNudge !== undefined ||
rule.subtitleNudgeInterval !== undefined) rule.subtitleNudgeInterval !== undefined)
) { );
ruleEl.querySelector(".override-subtitleNudge").checked = true; ruleEl.querySelector(".override-subtitleNudge").checked = hasSubtitleNudgeOverride;
ruleEl.querySelector(".site-subtitleNudge-container").style.display =
"block";
}
syncSiteRuleField(ruleEl, rule, "enableSubtitleNudge", true); syncSiteRuleField(ruleEl, rule, "enableSubtitleNudge", true);
syncSiteRuleField(ruleEl, rule, "subtitleNudgeInterval", false); syncSiteRuleField(ruleEl, rule, "subtitleNudgeInterval", false);
applySiteRuleOverrideState(
ruleEl,
"override-subtitleNudge",
"site-subtitleNudge-container"
);
if (rule && Array.isArray(rule.controllerButtons)) { var hasControlbarOverride = Boolean(rule && Array.isArray(rule.controllerButtons));
ruleEl.querySelector(".override-controlbar").checked = true; ruleEl.querySelector(".override-controlbar").checked = hasControlbarOverride;
var cbContainer = ruleEl.querySelector(".site-controlbar-container"); populateControlBarZones(
cbContainer.style.display = "block"; ruleEl.querySelector(".site-cb-active"),
populateControlBarZones( ruleEl.querySelector(".site-cb-available"),
ruleEl.querySelector(".site-cb-active"), hasControlbarOverride ? rule.controllerButtons : getControlBarOrder()
ruleEl.querySelector(".site-cb-available"), );
rule.controllerButtons applySiteRuleOverrideState(ruleEl, "override-controlbar", "site-controlbar-container");
);
}
if ( var hasPopupControlbarOverride = Boolean(
rule && rule &&
(rule.showPopupControlBar !== undefined || (rule.showPopupControlBar !== undefined ||
Array.isArray(rule.popupControllerButtons)) Array.isArray(rule.popupControllerButtons))
) { );
ruleEl.querySelector(".override-popup-controlbar").checked = true; ruleEl.querySelector(".override-popup-controlbar").checked =
var popupCbContainer = ruleEl.querySelector(".site-popup-controlbar-container"); hasPopupControlbarOverride;
popupCbContainer.style.display = "block"; populateControlBarZones(
var sitePopupActive = ruleEl.querySelector(".site-popup-cb-active"); ruleEl.querySelector(".site-popup-cb-active"),
var sitePopupAvailable = ruleEl.querySelector(".site-popup-cb-available"); ruleEl.querySelector(".site-popup-cb-available"),
if (Array.isArray(rule.popupControllerButtons)) { hasPopupControlbarOverride && Array.isArray(rule.popupControllerButtons)
populateControlBarZones( ? sanitizePopupButtonOrder(rule.popupControllerButtons)
sitePopupActive, : getPopupControlBarOrder(),
sitePopupAvailable, function (id) {
rule.popupControllerButtons return !popupExcludedButtonIds.has(id);
);
} else if (
sitePopupActive &&
sitePopupAvailable &&
sitePopupActive.children.length === 0
) {
populateControlBarZones(
sitePopupActive,
sitePopupAvailable,
getPopupControlBarOrder()
);
} }
} );
syncSiteRuleField(ruleEl, rule, "showPopupControlBar", true); syncSiteRuleField(ruleEl, rule, "showPopupControlBar", true);
applySiteRuleOverrideState(
ruleEl,
"override-popup-controlbar",
"site-popup-controlbar-container"
);
if (rule && Array.isArray(rule.shortcuts) && rule.shortcuts.length > 0) { var hasShortcutOverride = Boolean(
ruleEl.querySelector(".override-shortcuts").checked = true; rule && Array.isArray(rule.shortcuts) && rule.shortcuts.length > 0
var container = ruleEl.querySelector(".site-shortcuts-container"); );
container.style.display = "block"; ruleEl.querySelector(".override-shortcuts").checked = hasShortcutOverride;
var container = ruleEl.querySelector(".site-shortcuts-container");
if (hasShortcutOverride) {
rule.shortcuts.forEach((shortcut) => { rule.shortcuts.forEach((shortcut) => {
addSiteRuleShortcut( addSiteRuleShortcut(
container, container,
@@ -1085,13 +1132,41 @@ function createSiteRule(rule) {
shortcut.force shortcut.force
); );
}); });
} else {
populateDefaultSiteShortcuts(container);
} }
applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container");
document.getElementById("siteRulesContainer").appendChild(ruleEl); document.getElementById("siteRulesContainer").appendChild(ruleEl);
} }
function populateDefaultSiteShortcuts(container) { function populateDefaultSiteShortcuts(container) {
tcDefaults.keyBindings.forEach((binding) => { var bindings = [];
document.querySelectorAll("#customs .shortcut-row").forEach((row) => {
var action = row.dataset.action;
if (!action) return;
var keyInput = row.querySelector(".customKey");
var binding = normalizeStoredBinding(keyInput && keyInput.vscBinding);
if (!binding) return;
var valueInput = row.querySelector(".customValue");
bindings.push({
action: action,
code: binding.code,
disabled: binding.disabled === true,
value: customActionsNoValues.includes(action)
? 0
: Number(valueInput && valueInput.value),
force: false
});
});
if (bindings.length === 0) {
bindings = tcDefaults.keyBindings.slice();
}
bindings.forEach((binding) => {
addSiteRuleShortcut(container, binding.action, binding, binding.value, false); addSiteRuleShortcut(container, binding.action, binding, binding.value, false);
}); });
} }
@@ -1110,7 +1185,7 @@ function createControlBarBlock(buttonId) {
var icon = document.createElement("span"); var icon = document.createElement("span");
icon.className = "cb-icon"; icon.className = "cb-icon";
icon.textContent = def.icon; fillControlBarIconElement(icon, buttonId);
var label = document.createElement("span"); var label = document.createElement("span");
label.className = "cb-label"; label.className = "cb-label";
@@ -1123,16 +1198,23 @@ function createControlBarBlock(buttonId) {
return block; return block;
} }
function populateControlBarZones(activeZone, availableZone, activeIds) { function populateControlBarZones(activeZone, availableZone, activeIds, allowButtonId) {
activeZone.innerHTML = ""; vscClearElement(activeZone);
availableZone.innerHTML = ""; vscClearElement(availableZone);
var allowed = function (id) {
if (!controllerButtonDefs[id]) return false;
return typeof allowButtonId === "function" ? Boolean(allowButtonId(id)) : true;
};
activeIds.forEach(function (id) { activeIds.forEach(function (id) {
if (!allowed(id)) return;
var block = createControlBarBlock(id); var block = createControlBarBlock(id);
if (block) activeZone.appendChild(block); if (block) activeZone.appendChild(block);
}); });
Object.keys(controllerButtonDefs).forEach(function (id) { Object.keys(controllerButtonDefs).forEach(function (id) {
if (!allowed(id)) return;
if (!activeIds.includes(id)) { if (!activeIds.includes(id)) {
var block = createControlBarBlock(id); var block = createControlBarBlock(id);
if (block) availableZone.appendChild(block); if (block) availableZone.appendChild(block);
@@ -1160,15 +1242,21 @@ function getControlBarOrder() {
} }
function populatePopupControlBarEditor(activeIds) { function populatePopupControlBarEditor(activeIds) {
var popupActiveIds = sanitizePopupButtonOrder(activeIds);
populateControlBarZones( populateControlBarZones(
document.getElementById("popupControlBarActive"), document.getElementById("popupControlBarActive"),
document.getElementById("popupControlBarAvailable"), document.getElementById("popupControlBarAvailable"),
activeIds popupActiveIds,
function (id) {
return !popupExcludedButtonIds.has(id);
}
); );
} }
function getPopupControlBarOrder() { function getPopupControlBarOrder() {
return readControlBarOrder(document.getElementById("popupControlBarActive")); return sanitizePopupButtonOrder(
readControlBarOrder(document.getElementById("popupControlBarActive"))
);
} }
function updatePopupEditorDisabledState() { function updatePopupEditorDisabledState() {
@@ -1265,8 +1353,218 @@ function initControlBarEditor() {
}); });
} }
var lucidePickerSelectedSlug = null;
var lucideSearchTimer = null;
function setLucideStatus(msg) {
var el = document.getElementById("lucideIconStatus");
if (el) el.textContent = msg || "";
}
function repaintAllCbIconsFromCustomMap() {
document.querySelectorAll(".cb-block .cb-icon").forEach(function (icon) {
var block = icon.closest(".cb-block");
if (!block) return;
fillControlBarIconElement(icon, block.dataset.buttonId);
});
}
function persistCustomButtonIcons(map, callback) {
chrome.storage.local.set({ customButtonIcons: map }, function () {
if (chrome.runtime.lastError) {
setLucideStatus(
"Could not save icons: " + chrome.runtime.lastError.message
);
return;
}
customButtonIconsLive = map;
if (callback) callback();
repaintAllCbIconsFromCustomMap();
});
}
function initLucideButtonIconsUI() {
var actionSel = document.getElementById("lucideIconActionSelect");
var searchInput = document.getElementById("lucideIconSearch");
var resultsEl = document.getElementById("lucideIconResults");
var previewEl = document.getElementById("lucideIconPreview");
if (!actionSel || !searchInput || !resultsEl || !previewEl) return;
if (typeof getLucideTagsMap !== "function") return;
if (!actionSel.dataset.lucideInit) {
actionSel.dataset.lucideInit = "1";
vscClearElement(actionSel);
Object.keys(controllerButtonDefs).forEach(function (aid) {
if (aid === "nudge") {
Object.keys(lucideSubtitleNudgeActionLabels).forEach(function (subId) {
var o2 = document.createElement("option");
o2.value = subId;
o2.textContent =
lucideSubtitleNudgeActionLabels[subId] + " (" + subId + ")";
actionSel.appendChild(o2);
});
return;
}
var o = document.createElement("option");
o.value = aid;
o.textContent =
controllerButtonDefs[aid].name + " (" + aid + ")";
actionSel.appendChild(o);
});
}
function renderResults(slugs) {
vscClearElement(resultsEl);
slugs.forEach(function (slug) {
var b = document.createElement("button");
b.type = "button";
b.className = "lucide-result-tile";
b.dataset.slug = slug;
b.title = slug;
b.setAttribute("aria-label", slug);
if (slug === lucidePickerSelectedSlug) {
b.classList.add("lucide-picked");
}
var url =
typeof lucideIconSvgUrl === "function" ? lucideIconSvgUrl(slug) : "";
if (url) {
var img = document.createElement("img");
img.className = "lucide-result-thumb";
img.src = url;
img.alt = "";
img.loading = "lazy";
b.appendChild(img);
} else {
b.textContent = slug.slice(0, 3);
}
b.addEventListener("click", function () {
lucidePickerSelectedSlug = slug;
Array.prototype.forEach.call(
resultsEl.querySelectorAll("button"),
function (x) {
x.classList.toggle("lucide-picked", x.dataset.slug === slug);
}
);
fetchLucideSvg(slug)
.then(function (txt) {
var safe = sanitizeLucideSvg(txt);
if (!safe) throw new Error("Bad SVG");
if (!vscSetSvgContent(previewEl, safe)) {
throw new Error("Preview render failed");
}
setLucideStatus("Preview: " + slug);
})
.catch(function (e) {
vscClearElement(previewEl);
setLucideStatus(
"Could not load: " + slug + " — " + e.message
);
});
});
resultsEl.appendChild(b);
});
}
if (!searchInput.dataset.lucideBound) {
searchInput.dataset.lucideBound = "1";
searchInput.addEventListener("input", function () {
clearTimeout(lucideSearchTimer);
lucideSearchTimer = setTimeout(function () {
getLucideTagsMap(chrome.storage.local, false)
.then(function (map) {
var q = searchInput.value;
if (!q.trim()) {
vscClearElement(resultsEl);
return;
}
renderResults(searchLucideSlugs(map, q, 48));
})
.catch(function (e) {
setLucideStatus("Icon list error: " + e.message);
});
}, 200);
});
}
var applyBtn = document.getElementById("lucideIconApply");
if (applyBtn && !applyBtn.dataset.lucideBound) {
applyBtn.dataset.lucideBound = "1";
applyBtn.addEventListener("click", function () {
var action = actionSel.value;
var slug = lucidePickerSelectedSlug;
if (!action || !slug) {
setLucideStatus("Pick an action and click an icon first.");
return;
}
fetchLucideSvg(slug)
.then(function (txt) {
var safe = sanitizeLucideSvg(txt);
if (!safe) throw new Error("Sanitize failed");
var next = Object.assign({}, customButtonIconsLive);
next[action] = { slug: slug, svg: safe };
persistCustomButtonIcons(next, function () {
setLucideStatus(
"Saved " +
slug +
" for " +
action +
". Reload pages for the hover bar."
);
});
})
.catch(function (e) {
setLucideStatus("Apply failed: " + e.message);
});
});
}
var clrOne = document.getElementById("lucideIconClearAction");
if (clrOne && !clrOne.dataset.lucideBound) {
clrOne.dataset.lucideBound = "1";
clrOne.addEventListener("click", function () {
var action = actionSel.value;
if (!action) return;
var next = Object.assign({}, customButtonIconsLive);
delete next[action];
persistCustomButtonIcons(next, function () {
setLucideStatus("Cleared custom icon for " + action + ".");
});
});
}
var clrAll = document.getElementById("lucideIconClearAll");
if (clrAll && !clrAll.dataset.lucideBound) {
clrAll.dataset.lucideBound = "1";
clrAll.addEventListener("click", function () {
persistCustomButtonIcons({}, function () {
setLucideStatus("All custom icons cleared.");
});
});
}
var reloadTags = document.getElementById("lucideIconReloadTags");
if (reloadTags && !reloadTags.dataset.lucideBound) {
reloadTags.dataset.lucideBound = "1";
reloadTags.addEventListener("click", function () {
getLucideTagsMap(chrome.storage.local, true)
.then(function () {
setLucideStatus("Icon name list refreshed.");
})
.catch(function (e) {
setLucideStatus("Refresh failed: " + e.message);
});
});
}
}
function restore_options() { function restore_options() {
chrome.storage.sync.get(tcDefaults, function (storage) { chrome.storage.sync.get(tcDefaults, function (storage) {
chrome.storage.local.get(["customButtonIcons"], function (loc) {
customButtonIconsLive =
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
document.getElementById("rememberSpeed").checked = storage.rememberSpeed; document.getElementById("rememberSpeed").checked = storage.rememberSpeed;
document.getElementById("forceLastSavedSpeed").checked = document.getElementById("forceLastSavedSpeed").checked =
storage.forceLastSavedSpeed; storage.forceLastSavedSpeed;
@@ -1329,24 +1627,19 @@ function restore_options() {
valueInput.disabled = true; valueInput.disabled = true;
} }
} else if (valueInput) { } else if (valueInput) {
valueInput.value = item.value; valueInput.value = formatSpeedBindingDisplay(item.action, item.value);
} }
}); });
refreshAddShortcutSelector(); refreshAddShortcutSelector();
// Load site rules (use defaults if none in storage or if storage has empty array) // Load site rules (use defaults if none in storage or empty array)
var siteRules = Array.isArray(storage.siteRules) && storage.siteRules.length > 0 var siteRules =
? storage.siteRules Array.isArray(storage.siteRules) && storage.siteRules.length > 0
: (storage.blacklist ? migrateLegacyBlacklist(storage) : (tcDefaults.siteRules || [])); ? storage.siteRules
: tcDefaults.siteRules || [];
// If we migrated from blacklist, save the new format vscClearElement(document.getElementById("siteRulesContainer"));
if (storage.blacklist && siteRules.length > 0) {
chrome.storage.sync.set({ siteRules: siteRules });
chrome.storage.sync.remove(["blacklist"]);
}
document.getElementById("siteRulesContainer").innerHTML = "";
if (siteRules.length > 0) { if (siteRules.length > 0) {
siteRules.forEach((rule) => { siteRules.forEach((rule) => {
if (rule && rule.pattern) { if (rule && rule.pattern) {
@@ -1368,12 +1661,20 @@ function restore_options() {
: tcDefaults.popupControllerButtons; : tcDefaults.popupControllerButtons;
populatePopupControlBarEditor(popupButtons); populatePopupControlBarEditor(popupButtons);
updatePopupEditorDisabledState(); updatePopupEditorDisabledState();
initLucideButtonIconsUI();
});
}); });
} }
function restore_defaults() { function restore_defaults() {
document.querySelectorAll(".customs:not([id])").forEach((el) => el.remove()); document.querySelectorAll(".customs:not([id])").forEach((el) => el.remove());
chrome.storage.local.remove(
["customButtonIcons", "lucideTagsCacheV1", "lucideTagsCacheV1At"],
function () {}
);
chrome.storage.sync.set(tcDefaults, function () { chrome.storage.sync.set(tcDefaults, function () {
restore_options(); restore_options();
var status = document.getElementById("status"); var status = document.getElementById("status");
@@ -1437,29 +1738,26 @@ document.addEventListener("DOMContentLoaded", function () {
eventCaller(event, "customKey", recordKeyPress) eventCaller(event, "customKey", recordKeyPress)
); );
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (event.target.classList.contains("removeParent")) { var target = event.target;
event.target.parentNode.remove(); var targetEl = target && target.closest ? target : target.parentElement;
if (!targetEl) return;
var removeParentButton = targetEl.closest(".removeParent");
if (removeParentButton) {
removeParentButton.parentNode.remove();
refreshAddShortcutSelector(); refreshAddShortcutSelector();
return; return;
} }
if (event.target.classList.contains("remove-site-rule")) { var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
event.target.closest(".site-rule").remove(); if (removeSiteRuleButton) {
removeSiteRuleButton.closest(".site-rule").remove();
return; return;
} }
if (event.target.classList.contains("toggle-site-rule")) { var toggleButton = targetEl.closest(".toggle-site-rule");
var ruleEl = event.target.closest(".site-rule"); if (toggleButton) {
var ruleBody = ruleEl.querySelector(".site-rule-body"); var ruleEl = toggleButton.closest(".site-rule");
var isCollapsed = ruleEl.classList.contains("collapsed"); var isCollapsed = ruleEl.classList.contains("collapsed");
setSiteRuleExpandedState(ruleEl, isCollapsed);
if (isCollapsed) {
ruleBody.style.display = "block";
ruleEl.classList.remove("collapsed");
event.target.textContent = "\u2212";
} else {
ruleBody.style.display = "none";
ruleEl.classList.add("collapsed");
event.target.textContent = "\u002b";
}
return; return;
} }
}); });
@@ -1481,7 +1779,10 @@ document.addEventListener("DOMContentLoaded", function () {
"override-autohide": "site-autohide-container", "override-autohide": "site-autohide-container",
"override-playback": "site-playback-container", "override-playback": "site-playback-container",
"override-opacity": "site-opacity-container", "override-opacity": "site-opacity-container",
"override-subtitleNudge": "site-subtitleNudge-container" "override-subtitleNudge": "site-subtitleNudge-container",
"override-controlbar": "site-controlbar-container",
"override-popup-controlbar": "site-popup-controlbar-container",
"override-shortcuts": "site-shortcuts-container"
}; };
for (var ocb in siteOverrideContainers) { for (var ocb in siteOverrideContainers) {
if (event.target.classList.contains(ocb)) { if (event.target.classList.contains(ocb)) {
@@ -1490,75 +1791,10 @@ document.addEventListener("DOMContentLoaded", function () {
"." + siteOverrideContainers[ocb] "." + siteOverrideContainers[ocb]
); );
if (targetBox) { if (targetBox) {
targetBox.style.display = event.target.checked ? "block" : "none"; setSiteOverrideContainerState(targetBox, event.target.checked);
} }
return; return;
} }
} }
// Handle site rule override checkboxes
if (event.target.classList.contains("override-shortcuts")) {
var container = event.target
.closest(".site-rule-shortcuts")
.querySelector(".site-shortcuts-container");
if (event.target.checked) {
container.style.display = "block";
if (container.children.length === 0) {
populateDefaultSiteShortcuts(container);
}
} else {
container.style.display = "none";
}
}
if (event.target.classList.contains("override-controlbar")) {
var cbContainer = event.target
.closest(".site-rule-controlbar")
.querySelector(".site-controlbar-container");
if (event.target.checked) {
cbContainer.style.display = "block";
var activeZone = cbContainer.querySelector(".site-cb-active");
var availableZone = cbContainer.querySelector(".site-cb-available");
if (
activeZone &&
availableZone &&
activeZone.children.length === 0 &&
availableZone.children.length === 0
) {
populateControlBarZones(
activeZone,
availableZone,
getControlBarOrder()
);
}
} else {
cbContainer.style.display = "none";
}
}
if (event.target.classList.contains("override-popup-controlbar")) {
var popupCbContainer = event.target
.closest(".site-rule-controlbar")
.querySelector(".site-popup-controlbar-container");
if (event.target.checked) {
popupCbContainer.style.display = "block";
var popupActiveZone = popupCbContainer.querySelector(".site-popup-cb-active");
var popupAvailableZone = popupCbContainer.querySelector(".site-popup-cb-available");
if (
popupActiveZone &&
popupAvailableZone &&
popupActiveZone.children.length === 0 &&
popupAvailableZone.children.length === 0
) {
populateControlBarZones(
popupActiveZone,
popupAvailableZone,
getPopupControlBarOrder()
);
}
} else {
popupCbContainer.style.display = "none";
}
}
}); });
}); });
+2128
View File
@@ -0,0 +1,2128 @@
{
"name": "speeder",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "speeder",
"devDependencies": {
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nwsapi": {
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.1",
"@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "speeder",
"private": true,
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
}
}
+16
View File
@@ -159,6 +159,22 @@ button:focus-visible {
opacity: 0.55; opacity: 0.55;
} }
.popup-control-bar button .vsc-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
line-height: 0;
vertical-align: middle;
}
.popup-control-bar button .vsc-btn-icon svg {
width: 100%;
height: 100%;
flex-shrink: 0;
}
.popup-status { .popup-status {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
+3
View File
@@ -4,6 +4,9 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Speeder</title> <title>Speeder</title>
<link rel="stylesheet" href="popup.css" /> <link rel="stylesheet" href="popup.css" />
<script src="shared/site-rules.js"></script>
<script src="shared/popup-controls.js"></script>
<script src="ui-icons.js"></script>
<script src="popup.js"></script> <script src="popup.js"></script>
</head> </head>
<body> <body>
+108 -113
View File
@@ -1,115 +1,52 @@
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; var speederShared =
typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
var siteRuleUtils = speederShared.siteRules || {};
var popupControlUtils = speederShared.popupControls || {};
/* `label` is only used if ui-icons.js has no path for this action (fallback). */
var controllerButtonDefs = { var controllerButtonDefs = {
rewind: { label: "\u00AB", className: "rw" }, rewind: { label: "", className: "rw" },
slower: { label: "\u2212", className: "" }, slower: { label: "", className: "" },
faster: { label: "+", className: "" }, faster: { label: "", className: "" },
advance: { label: "\u00BB", className: "rw" }, advance: { label: "", className: "rw" },
display: { label: "\u00D7", className: "hideButton" }, display: { label: "", className: "hideButton" },
reset: { label: "\u21BA", className: "" }, reset: { label: "\u21BB", className: "" },
fast: { label: "\u2605", className: "" }, fast: { label: "", className: "" },
settings: { label: "\u2699", className: "" }, nudge: { label: "", className: "" },
pause: { label: "\u23EF", className: "" }, settings: { label: "", className: "" },
muted: { label: "M", className: "" }, pause: { label: "", className: "" },
mark: { label: "\u2691", className: "" }, muted: { label: "", className: "" },
jump: { label: "\u21E5", className: "" } mark: { label: "", className: "" },
jump: { label: "", className: "" }
}; };
var defaultButtons = ["rewind", "slower", "faster", "advance", "display"]; var defaultButtons = ["rewind", "slower", "faster", "advance", "display"];
var popupExcludedButtonIds = new Set(["settings"]);
var storageDefaults = { var storageDefaults = {
enabled: true, enabled: true,
showPopupControlBar: true, showPopupControlBar: true,
controllerButtons: defaultButtons, controllerButtons: defaultButtons,
popupMatchHoverControls: true, popupMatchHoverControls: true,
popupControllerButtons: defaultButtons, popupControllerButtons: defaultButtons,
siteRules: [], siteRules: []
blacklist: `\
www.instagram.com
twitter.com
vine.co
imgur.com
teams.microsoft.com
`.replace(/^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm, "")
}; };
var renderToken = 0; var renderToken = 0;
function escapeStringRegExp(str) {
const m = /[|\\{}()[\]^$+*?.]/g;
return str.replace(m, "\\$&");
}
function isBlacklisted(url, blacklist) {
let b = false;
const l = blacklist ? blacklist.split("\n") : [];
l.forEach((m) => {
if (b) return;
m = m.replace(regStrip, "");
if (m.length == 0) return;
let r;
if (m.startsWith("/") && m.lastIndexOf("/") > 0) {
try {
const ls = m.lastIndexOf("/");
r = new RegExp(m.substring(1, ls), m.substring(ls + 1));
} catch (e) {
return;
}
} else r = new RegExp(escapeStringRegExp(m));
if (r && r.test(url)) b = true;
});
return b;
}
function matchSiteRule(url, siteRules) { function matchSiteRule(url, siteRules) {
if (!url || !Array.isArray(siteRules)) return null; return siteRuleUtils.matchSiteRule(url, siteRules);
for (var i = 0; i < siteRules.length; i++) {
var rule = siteRules[i];
if (!rule || !rule.pattern) continue;
var pattern = rule.pattern.replace(regStrip, "");
if (pattern.length === 0) continue;
var re;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var ls = pattern.lastIndexOf("/");
re = new RegExp(pattern.substring(1, ls), pattern.substring(ls + 1));
} catch (e) {
continue;
}
} else {
re = new RegExp(escapeStringRegExp(pattern));
}
if (re && re.test(url)) return rule;
}
return null;
} }
function isSiteRuleDisabled(rule) { function isSiteRuleDisabled(rule) {
return Boolean( return siteRuleUtils.isSiteRuleDisabled(rule);
rule &&
(rule.enabled === false || rule.disableExtension === true)
);
} }
function resolvePopupButtons(storage, siteRule) { function resolvePopupButtons(storage, siteRule) {
if (siteRule && Array.isArray(siteRule.popupControllerButtons)) { return popupControlUtils.resolvePopupButtons(storage, siteRule, {
return siteRule.popupControllerButtons; controllerButtonDefs: controllerButtonDefs,
} defaultButtons: defaultButtons,
excludedIds: popupExcludedButtonIds
if (storage.popupMatchHoverControls) { });
if (siteRule && Array.isArray(siteRule.controllerButtons)) {
return siteRule.controllerButtons;
}
if (Array.isArray(storage.controllerButtons)) {
return storage.controllerButtons;
}
}
if (Array.isArray(storage.popupControllerButtons)) {
return storage.popupControllerButtons;
}
return defaultButtons;
} }
function setControlBarVisible(visible) { function setControlBarVisible(visible) {
@@ -171,29 +108,83 @@ document.addEventListener("DOMContentLoaded", function () {
if (el) el.textContent = (speed != null ? Number(speed) : 1).toFixed(2); if (el) el.textContent = (speed != null ? Number(speed) : 1).toFixed(2);
} }
function applySpeedAndResetFromResponse(response) {
if (response && response.speed != null) {
updateSpeedDisplay(response.speed);
}
}
function pickBestFrameSpeedResult(results) {
return popupControlUtils.pickBestFrameSpeedResult(results);
}
function querySpeed() { function querySpeed() {
sendToActiveTab({ action: "get_speed" }, function (response) { chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
if (response && response.speed != null) { if (!tabs[0] || tabs[0].id == null) {
updateSpeedDisplay(response.speed); return;
} }
var tabId = tabs[0].id;
chrome.tabs.executeScript(
tabId,
{ allFrames: true, file: "frameSpeedSnapshot.js" },
function (results) {
if (chrome.runtime.lastError) {
sendToActiveTab({ action: "get_speed" }, function (response) {
applySpeedAndResetFromResponse(response || { speed: 1 });
});
return;
}
var best = pickBestFrameSpeedResult(results);
if (best) {
applySpeedAndResetFromResponse(best);
} else {
sendToActiveTab({ action: "get_speed" }, function (response) {
applySpeedAndResetFromResponse(response || { speed: 1 });
});
}
}
);
}); });
} }
function buildControlBar(buttons) { function buildControlBar(buttons, customIconsMap) {
var bar = document.getElementById("popupControlBar"); var bar = document.getElementById("popupControlBar");
if (!bar) return; if (!bar) return;
var existing = bar.querySelectorAll("button"); var existing = bar.querySelectorAll("button");
existing.forEach(function (btn) { btn.remove(); }); existing.forEach(function (btn) { btn.remove(); });
var customMap = customIconsMap || {};
buttons.forEach(function (btnId) { buttons.forEach(function (btnId) {
if (btnId === "nudge") return;
var def = controllerButtonDefs[btnId]; var def = controllerButtonDefs[btnId];
if (!def) return; if (!def) return;
var btn = document.createElement("button"); var btn = document.createElement("button");
btn.dataset.action = btnId; btn.dataset.action = btnId;
btn.textContent = def.label; var customEntry = customMap[btnId];
if (customEntry && customEntry.svg) {
var customSpan = vscCreateSvgWrap(document, customEntry.svg, "vsc-btn-icon");
if (customSpan) {
btn.appendChild(customSpan);
} else {
btn.textContent = def.label || "?";
}
} else if (typeof vscIconSvgString === "function") {
var svgStr = vscIconSvgString(btnId, 16);
if (svgStr) {
var iconSpan = vscCreateSvgWrap(document, svgStr, "vsc-btn-icon");
if (iconSpan) {
btn.appendChild(iconSpan);
} else {
btn.textContent = def.label || "?";
}
} else {
btn.textContent = def.label || "?";
}
} else {
btn.textContent = def.label || "?";
}
if (def.className) btn.className = def.className; if (def.className) btn.className = def.className;
btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1); btn.title = btnId.charAt(0).toUpperCase() + btnId.slice(1);
@@ -204,10 +195,8 @@ document.addEventListener("DOMContentLoaded", function () {
} }
sendToActiveTab( sendToActiveTab(
{ action: "run_action", actionName: btnId }, { action: "run_action", actionName: btnId },
function (response) { function () {
if (response && response.speed != null) { querySpeed();
updateSpeedDisplay(response.speed);
}
} }
); );
}); });
@@ -272,18 +261,23 @@ document.addEventListener("DOMContentLoaded", function () {
function renderForActiveTab() { function renderForActiveTab() {
var currentRenderToken = ++renderToken; var currentRenderToken = ++renderToken;
chrome.storage.sync.get(storageDefaults, function (storage) { chrome.storage.local.get(["customButtonIcons"], function (loc) {
if (currentRenderToken !== renderToken) return; if (currentRenderToken !== renderToken) return;
var customIconsMap =
loc && loc.customButtonIcons && typeof loc.customButtonIcons === "object"
? loc.customButtonIcons
: {};
chrome.storage.sync.get(storageDefaults, function (storage) {
if (currentRenderToken !== renderToken) return;
getActiveTabContext(function (context) { getActiveTabContext(function (context) {
if (currentRenderToken !== renderToken) return; if (currentRenderToken !== renderToken) return;
var url = context && context.url ? context.url : ""; var url = context && context.url ? context.url : "";
var siteRule = matchSiteRule(url, storage.siteRules); var siteRule = matchSiteRule(url, storage.siteRules);
var blacklisted = isBlacklisted(url, storage.blacklist);
var siteDisabled = isSiteRuleDisabled(siteRule); var siteDisabled = isSiteRuleDisabled(siteRule);
var siteAvailable = var siteAvailable = storage.enabled !== false && !siteDisabled;
storage.enabled !== false && !blacklisted && !siteDisabled;
var showBar = storage.showPopupControlBar !== false; var showBar = storage.showPopupControlBar !== false;
if (siteRule && siteRule.showPopupControlBar !== undefined) { if (siteRule && siteRule.showPopupControlBar !== undefined) {
@@ -291,15 +285,12 @@ document.addEventListener("DOMContentLoaded", function () {
} }
toggleEnabledUI(storage.enabled !== false); toggleEnabledUI(storage.enabled !== false);
buildControlBar(resolvePopupButtons(storage, siteRule)); buildControlBar(
resolvePopupButtons(storage, siteRule),
customIconsMap
);
setControlBarVisible(siteAvailable && showBar); setControlBarVisible(siteAvailable && showBar);
if (blacklisted) {
setStatusMessage("Site is blacklisted.");
updateSpeedDisplay(1);
return;
}
if (siteDisabled) { if (siteDisabled) {
setStatusMessage("Speeder is disabled for this site."); setStatusMessage("Speeder is disabled for this site.");
updateSpeedDisplay(1); updateSpeedDisplay(1);
@@ -313,6 +304,7 @@ document.addEventListener("DOMContentLoaded", function () {
updateSpeedDisplay(1); updateSpeedDisplay(1);
} }
}); });
});
}); });
} }
@@ -328,6 +320,10 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
chrome.storage.onChanged.addListener(function (changes, areaName) { chrome.storage.onChanged.addListener(function (changes, areaName) {
if (areaName === "local" && changes.customButtonIcons) {
renderForActiveTab();
return;
}
if (areaName !== "sync") return; if (areaName !== "sync") return;
if ( if (
changes.enabled || changes.enabled ||
@@ -335,8 +331,7 @@ document.addEventListener("DOMContentLoaded", function () {
changes.controllerButtons || changes.controllerButtons ||
changes.popupMatchHoverControls || changes.popupMatchHoverControls ||
changes.popupControllerButtons || changes.popupControllerButtons ||
changes.siteRules || changes.siteRules
changes.blacklist
) { ) {
renderForActiveTab(); renderForActiveTab();
} }
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Squash beta onto main, set manifest version, one release commit, push stable tag (v* without -beta).
# Does not merge dev or push to beta — promote only what is already on beta.
# Triggers .github/workflows/deploy.yml: listed AMO submission.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
manifest_version() {
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
}
bump_manifest() {
local ver="$1"
VER="$ver" python3 <<'PY'
import json
import os
ver = os.environ["VER"]
path = "manifest.json"
with open(path, encoding="utf-8") as f:
data = json.load(f)
data["version"] = ver
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
}
normalize_semver() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
s="${s#v}"
s="${s#V}"
printf '%s' "$s"
}
validate_semver() {
local s="$1"
if [[ -z "$s" ]]; then
echo "Error: empty version." >&2
return 1
fi
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "Error: invalid version (use something like 5.0.4)." >&2
return 1
fi
}
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean. Commit or stash before releasing." >&2
exit 1
fi
git checkout beta
git pull origin beta
echo "Current version on beta (manifest.json): $(manifest_version)"
read -r -p "Release version for manifest.json + tag (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER"
TAG="v${SEMVER}"
if [[ "$TAG" == *-beta* ]]; then
echo "Warning: stable tags should not contain '-beta' (workflow would use unlisted + prerelease, not AMO listed)."
read -r -p "Continue anyway? [y/N] " w
[[ "${w:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
fi
echo
echo "This will:"
echo " 1. checkout main, merge --squash origin/beta (single release commit on main)"
echo " 2. set manifest.json to $SEMVER in that commit (if anything else changed, it is included too)"
echo " 3. push origin main, create tag $TAG, push tag (triggers listed AMO submit)"
echo " 4. checkout dev (merge main→dev yourself if you want them aligned)"
read -r -p "Continue? [y/N] " confirm
[[ "${confirm:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
echo "🚀 Releasing stable $TAG to AMO (listed)"
git checkout main
git pull origin main
git merge --squash beta
bump_manifest "$SEMVER"
git add -A
git commit -m "Release $TAG"
git push origin main
git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"
git checkout dev
echo "✅ Done: main squashed from beta, tagged $TAG (manifest $SEMVER)"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Merge dev → beta, push beta, and push an annotated beta tag (v*-beta*).
# Triggers .github/workflows/deploy.yml: unlisted AMO sign + GitHub prerelease.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
manifest_version() {
python3 -c 'import json; print(json.load(open("manifest.json"))["version"])'
}
bump_manifest() {
local ver="$1"
VER="$ver" python3 <<'PY'
import json
import os
ver = os.environ["VER"]
path = "manifest.json"
with open(path, encoding="utf-8") as f:
data = json.load(f)
data["version"] = ver
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
}
normalize_semver() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
s="${s#v}"
s="${s#V}"
printf '%s' "$s"
}
validate_semver() {
local s="$1"
if [[ -z "$s" ]]; then
echo "Error: empty version." >&2
return 1
fi
if [[ ! "$s" =~ ^[0-9]+(\.[0-9]+){0,3}(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "Error: invalid version (use something like 5.0.4 or 5.0.4-beta.1)." >&2
return 1
fi
}
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean. Commit or stash before releasing." >&2
exit 1
fi
git checkout dev
git pull origin dev
echo "Current version in manifest.json: $(manifest_version)"
read -r -p "New version for manifest.json (e.g. 5.0.4): " SEMVER_IN
SEMVER="$(normalize_semver "$SEMVER_IN")"
validate_semver "$SEMVER"
echo "Beta git tag will include '-beta' (required by deploy.yml)."
read -r -p "Beta tag suffix [beta.1]: " SUFFIX_IN
SUFFIX="${SUFFIX_IN#"${SUFFIX_IN%%[![:space:]]*}"}"
SUFFIX="${SUFFIX%"${SUFFIX##*[![:space:]]}"}"
SUFFIX="${SUFFIX:-beta.1}"
TAG="v${SEMVER}-${SUFFIX}"
if [[ "$TAG" != *-beta* ]]; then
echo "Error: beta tag must contain '-beta' for the workflow (got $TAG). Try suffix like beta.1." >&2
exit 1
fi
echo
echo "This will:"
echo " 1. set manifest.json version to $SEMVER, commit on dev, push origin dev"
echo " 2. checkout beta, merge dev (no-ff), push origin beta"
echo " 3. create tag $TAG and push it (triggers beta AMO + prerelease)"
echo " 4. checkout dev (main is not modified)"
read -r -p "Continue? [y/N] " confirm
[[ "${confirm:-}" =~ ^[yY](es)?$ ]] || { echo "Aborted."; exit 1; }
echo "🚀 Releasing beta $TAG"
bump_manifest "$SEMVER"
git add manifest.json
git commit -m "Bump version to $SEMVER"
git push origin dev
git checkout beta
git pull origin beta
git merge dev --no-ff -m "$TAG"
git push origin beta
git tag -a "$TAG" -m "$TAG"
git push origin "$TAG"
git checkout dev
git pull origin dev
echo "✅ Done: beta $TAG (manifest $SEMVER; dev + beta + tag pushed)"
+644
View File
@@ -0,0 +1,644 @@
(function (global) {
"use strict";
var SITE_RULES_DIFF_FORMAT = "defaults-diff-v1";
var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"];
var SITE_RULE_OVERRIDE_KEYS = [
"controllerLocation",
"controllerMarginTop",
"controllerMarginBottom",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"controllerOpacity",
"enableSubtitleNudge",
"subtitleNudgeInterval",
"controllerButtons",
"showPopupControlBar",
"popupControllerButtons",
"shortcuts",
"preferredSpeed"
];
var DIFFABLE_OPTION_KEYS = [
"rememberSpeed",
"forceLastSavedSpeed",
"audioBoolean",
"enabled",
"startHidden",
"hideWithControls",
"hideWithControlsTimer",
"controllerLocation",
"controllerOpacity",
"controllerMarginTop",
"controllerMarginBottom",
"keyBindings",
"siteRules",
"siteRulesMeta",
"siteRulesFormat",
"controllerButtons",
"showPopupControlBar",
"popupMatchHoverControls",
"popupControllerButtons",
"enableSubtitleNudge",
"subtitleNudgeInterval",
"subtitleNudgeAmount"
];
var MANAGED_SYNC_KEYS = DIFFABLE_OPTION_KEYS.concat([
"hideWithYouTubeControls"
]);
var DEFAULT_SETTINGS = {
speed: 1.0,
lastSpeed: 1.0,
displayKeyCode: 86,
rememberSpeed: false,
audioBoolean: false,
startHidden: false,
hideWithYouTubeControls: false,
hideWithControls: false,
hideWithControlsTimer: 2.0,
controllerLocation: "top-left",
forceLastSavedSpeed: false,
enabled: true,
controllerOpacity: 0.3,
controllerMarginTop: 0,
controllerMarginRight: 0,
controllerMarginBottom: 65,
controllerMarginLeft: 0,
keyBindings: [
{
action: "display",
key: "V",
keyCode: 86,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "move",
key: "P",
keyCode: 80,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "slower",
key: "S",
keyCode: 83,
code: null,
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "faster",
key: "D",
keyCode: 68,
code: null,
disabled: false,
value: 0.1,
force: false,
predefined: true
},
{
action: "rewind",
key: "Z",
keyCode: 90,
code: null,
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "advance",
key: "X",
keyCode: 88,
code: null,
disabled: false,
value: 10,
force: false,
predefined: true
},
{
action: "reset",
key: "R",
keyCode: 82,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
},
{
action: "fast",
key: "G",
keyCode: 71,
code: null,
disabled: false,
value: 1.8,
force: false,
predefined: true
},
{
action: "toggleSubtitleNudge",
key: "N",
keyCode: 78,
code: null,
disabled: false,
value: 0,
force: false,
predefined: true
}
],
siteRules: [
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/",
enabled: true,
enableSubtitleNudge: true,
subtitleNudgeInterval: 50
},
{
pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/shorts\\/.*/",
enabled: true,
rememberSpeed: true,
controllerMarginTop: 60,
controllerMarginBottom: 85
}
],
controllerButtons: DEFAULT_BUTTONS.slice(),
showPopupControlBar: true,
popupMatchHoverControls: true,
popupControllerButtons: DEFAULT_BUTTONS.slice(),
enableSubtitleNudge: false,
subtitleNudgeInterval: 50,
subtitleNudgeAmount: 0.001
};
function clonePlainData(value) {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value));
}
function hasOwn(obj, key) {
return Boolean(obj) && Object.prototype.hasOwnProperty.call(obj, key);
}
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function sortComparableValue(value) {
if (Array.isArray(value)) {
return value.map(sortComparableValue);
}
if (isPlainObject(value)) {
var sorted = {};
Object.keys(value)
.sort()
.forEach(function (key) {
if (value[key] === undefined) {
return;
}
sorted[key] = sortComparableValue(value[key]);
});
return sorted;
}
return value;
}
function areComparableValuesEqual(a, b) {
return (
JSON.stringify(sortComparableValue(a)) ===
JSON.stringify(sortComparableValue(b))
);
}
function deepMergeDefaults(defaults, overrides) {
if (Array.isArray(defaults)) {
return Array.isArray(overrides)
? clonePlainData(overrides)
: clonePlainData(defaults);
}
if (isPlainObject(defaults)) {
var result = clonePlainData(defaults) || {};
if (!isPlainObject(overrides)) {
return result;
}
Object.keys(overrides).forEach(function (key) {
if (overrides[key] === undefined) {
return;
}
if (hasOwn(defaults, key)) {
result[key] = deepMergeDefaults(defaults[key], overrides[key]);
} else {
result[key] = clonePlainData(overrides[key]);
}
});
return result;
}
return overrides === undefined
? clonePlainData(defaults)
: clonePlainData(overrides);
}
function deepDiff(current, defaults) {
if (current === undefined) {
return undefined;
}
if (Array.isArray(current)) {
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
if (isPlainObject(current)) {
var result = {};
Object.keys(current).forEach(function (key) {
var diff = deepDiff(current[key], defaults && defaults[key]);
if (diff !== undefined) {
result[key] = diff;
}
});
return Object.keys(result).length > 0 ? result : undefined;
}
return areComparableValuesEqual(current, defaults)
? undefined
: clonePlainData(current);
}
function getDefaultSiteRules() {
return clonePlainData(DEFAULT_SETTINGS.siteRules) || [];
}
function getDefaultSiteRulesByPattern() {
var map = Object.create(null);
getDefaultSiteRules().forEach(function (rule) {
if (!rule || typeof rule.pattern !== "string" || !rule.pattern) {
return;
}
map[rule.pattern] = rule;
});
return map;
}
function normalizeSiteRuleForDiff(rule, baseSettings) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return null;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (!pattern) {
return null;
}
var normalized = { pattern: pattern };
var baseEnabled = hasOwn(baseSettings, "enabled")
? Boolean(baseSettings.enabled)
: true;
var ruleEnabled = hasOwn(rule, "enabled")
? Boolean(rule.enabled)
: hasOwn(rule, "disableExtension")
? !Boolean(rule.disableExtension)
: baseEnabled;
if (!areComparableValuesEqual(ruleEnabled, baseEnabled)) {
normalized.enabled = ruleEnabled;
}
SITE_RULE_OVERRIDE_KEYS.forEach(function (key) {
var baseValue = clonePlainData(baseSettings[key]);
var effectiveValue = hasOwn(rule, key)
? clonePlainData(rule[key])
: baseValue;
if (!areComparableValuesEqual(effectiveValue, baseValue)) {
normalized[key] = effectiveValue;
}
});
Object.keys(rule).forEach(function (key) {
if (
key === "pattern" ||
key === "enabled" ||
key === "disableExtension" ||
SITE_RULE_OVERRIDE_KEYS.indexOf(key) !== -1 ||
rule[key] === undefined
) {
return;
}
normalized[key] = clonePlainData(rule[key]);
});
return normalized;
}
function compressSiteRules(siteRules, baseSettings) {
if (!Array.isArray(siteRules)) {
return {};
}
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
var currentPatterns = new Set();
var exportRules = [];
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (pattern) {
currentPatterns.add(pattern);
}
var normalizedRule = normalizeSiteRuleForDiff(rule, baseSettings);
if (!normalizedRule || Object.keys(normalizedRule).length === 1) {
return;
}
var defaultRule = pattern ? defaultRulesByPattern[pattern] : null;
var normalizedDefaultRule = defaultRule
? normalizeSiteRuleForDiff(defaultRule, baseSettings)
: null;
if (normalizedDefaultRule) {
if (areComparableValuesEqual(normalizedRule, normalizedDefaultRule)) {
return;
}
var defaultRuleDiff = deepDiff(normalizedRule, normalizedDefaultRule);
if (defaultRuleDiff && Object.keys(defaultRuleDiff).length > 0) {
defaultRuleDiff.pattern = pattern;
exportRules.push(defaultRuleDiff);
}
return;
}
exportRules.push(normalizedRule);
});
var removedDefaultPatterns = defaultRules
.map(function (rule) {
return rule && typeof rule.pattern === "string" ? rule.pattern : "";
})
.filter(function (pattern) {
return pattern && !currentPatterns.has(pattern);
});
var result = {};
if (exportRules.length > 0) {
result.siteRules = exportRules;
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
if (removedDefaultPatterns.length > 0) {
result.siteRulesMeta = {
removedDefaultPatterns: removedDefaultPatterns
};
result.siteRulesFormat = SITE_RULES_DIFF_FORMAT;
}
return result;
}
function expandSiteRules(siteRules, siteRulesMeta) {
var defaultRules = getDefaultSiteRules();
var defaultRulesByPattern = getDefaultSiteRulesByPattern();
if (defaultRules.length === 0) {
return Array.isArray(siteRules) ? clonePlainData(siteRules) : [];
}
var removedDefaultPatterns = new Set(
siteRulesMeta && Array.isArray(siteRulesMeta.removedDefaultPatterns)
? siteRulesMeta.removedDefaultPatterns
: []
);
var modifiedDefaultRules = Object.create(null);
var customRules = [];
if (Array.isArray(siteRules)) {
siteRules.forEach(function (rule) {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
return;
}
var pattern = typeof rule.pattern === "string" ? rule.pattern.trim() : "";
if (
pattern &&
Object.prototype.hasOwnProperty.call(defaultRulesByPattern, pattern)
) {
modifiedDefaultRules[pattern] = clonePlainData(rule);
return;
}
customRules.push(clonePlainData(rule));
});
}
var mergedRules = [];
defaultRules.forEach(function (rule) {
var pattern = rule && typeof rule.pattern === "string" ? rule.pattern : "";
if (!pattern || removedDefaultPatterns.has(pattern)) {
return;
}
if (modifiedDefaultRules[pattern]) {
mergedRules.push(
Object.assign(
{},
clonePlainData(rule),
clonePlainData(modifiedDefaultRules[pattern])
)
);
return;
}
mergedRules.push(clonePlainData(rule));
});
customRules.forEach(function (rule) {
mergedRules.push(rule);
});
return mergedRules;
}
function buildStoredSettingsDiff(currentSettings) {
var defaults = clonePlainData(DEFAULT_SETTINGS);
var normalized = deepMergeDefaults(defaults, currentSettings || {});
var siteRuleData = compressSiteRules(normalized.siteRules, normalized);
var diffDefaults = {};
var diff = {};
delete normalized.siteRules;
delete normalized.siteRulesMeta;
delete normalized.siteRulesFormat;
delete normalized.hideWithYouTubeControls;
if (siteRuleData.siteRules) {
normalized.siteRules = siteRuleData.siteRules;
}
if (siteRuleData.siteRulesMeta) {
normalized.siteRulesMeta = siteRuleData.siteRulesMeta;
}
if (siteRuleData.siteRulesFormat) {
normalized.siteRulesFormat = siteRuleData.siteRulesFormat;
}
DIFFABLE_OPTION_KEYS.forEach(function (key) {
if (hasOwn(DEFAULT_SETTINGS, key)) {
diffDefaults[key] = clonePlainData(DEFAULT_SETTINGS[key]);
}
if (!hasOwn(normalized, key)) {
return;
}
var valueDiff = deepDiff(normalized[key], diffDefaults[key]);
if (valueDiff !== undefined) {
diff[key] = valueDiff;
}
});
return diff;
}
function expandStoredSettings(storage) {
var raw = clonePlainData(storage) || {};
var expanded = deepMergeDefaults(DEFAULT_SETTINGS, raw);
if (
!hasOwn(raw, "hideWithControls") &&
hasOwn(raw, "hideWithYouTubeControls")
) {
expanded.hideWithControls = Boolean(raw.hideWithYouTubeControls);
}
expanded.hideWithYouTubeControls = expanded.hideWithControls;
if (raw.siteRulesFormat === SITE_RULES_DIFF_FORMAT) {
expanded.siteRules = expandSiteRules(raw.siteRules, raw.siteRulesMeta);
} else if (Array.isArray(raw.siteRules)) {
expanded.siteRules = clonePlainData(raw.siteRules);
} else {
expanded.siteRules = getDefaultSiteRules();
}
return expanded;
}
function escapeStringRegExp(str) {
var matcher = /[|\\{}()[\]^$+*?.]/g;
return String(str).replace(matcher, "\\$&");
}
function siteRuleMatchesUrl(rule, currentUrl) {
if (!rule || !rule.pattern || !currentUrl) {
return false;
}
var pattern = String(rule.pattern).trim();
if (!pattern) {
return false;
}
var regex;
if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
try {
var lastSlash = pattern.lastIndexOf("/");
regex = new RegExp(
pattern.substring(1, lastSlash),
pattern.substring(lastSlash + 1)
);
} catch (_error) {
return false;
}
} else {
regex = new RegExp(escapeStringRegExp(pattern));
}
return Boolean(regex && regex.test(currentUrl));
}
function mergeMatchingSiteRules(currentUrl, siteRules) {
if (!currentUrl || !Array.isArray(siteRules)) {
return null;
}
var matchedRules = [];
for (var i = 0; i < siteRules.length; i++) {
if (siteRuleMatchesUrl(siteRules[i], currentUrl)) {
matchedRules.push(siteRules[i]);
}
}
if (!matchedRules.length) {
return null;
}
var mergedRule = {};
matchedRules.forEach(function (rule) {
Object.keys(rule).forEach(function (key) {
var value = rule[key];
if (Array.isArray(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
if (isPlainObject(value)) {
mergedRule[key] = clonePlainData(value);
return;
}
mergedRule[key] = value;
});
});
return mergedRule;
}
function isSiteRuleDisabled(rule) {
return Boolean(
rule &&
(
rule.enabled === false ||
(typeof rule.enabled === "undefined" && rule.disableExtension === true)
)
);
}
global.vscClonePlainData = clonePlainData;
global.vscAreComparableValuesEqual = areComparableValuesEqual;
global.vscDeepMergeDefaults = deepMergeDefaults;
global.vscBuildStoredSettingsDiff = buildStoredSettingsDiff;
global.vscExpandStoredSettings = expandStoredSettings;
global.vscGetSettingsDefaults = function () {
return clonePlainData(DEFAULT_SETTINGS);
};
global.vscGetManagedSyncKeys = function () {
return MANAGED_SYNC_KEYS.slice();
};
global.vscGetSiteRulesDiffFormat = function () {
return SITE_RULES_DIFF_FORMAT;
};
global.vscMatchSiteRule = mergeMatchingSiteRules;
global.vscSiteRuleMatchesUrl = siteRuleMatchesUrl;
global.vscIsSiteRuleDisabled = isSiteRuleDisabled;
})(typeof globalThis !== "undefined" ? globalThis : this);
+130 -24
View File
@@ -4,10 +4,20 @@
font-size: 13px; font-size: 13px;
} }
/* Global * uses 1.9em line-height; without this, every node inside #controller
(including svg) keeps a tall line box and the bar grows + content rides high. */
#controller * {
line-height: 1;
}
/* Show extra buttons on hover or keyboard :focus-visible only. Plain :focus-within
after a mouse click kept #controls visible while hover-only rules (e.g. draggable
margin) turned off when the pointer left the bar. */
#controller:hover #controls, #controller:hover #controls,
#controller:focus-within #controls, #controller:focus-within:has(:focus-visible) #controls,
:host(:hover) #controls { :host(:hover) #controls {
display: inline; display: inline-flex;
vertical-align: middle;
} }
#controller { #controller {
@@ -40,8 +50,9 @@
opacity: 0.7; opacity: 0.7;
} }
/* Space between speed readout and hover buttons — tweak this value (px) as you like */
#controller:hover > .draggable { #controller:hover > .draggable {
margin-right: 0.8em; margin-right: 5px;
} }
/* Center presets: midpoint between left- and right-preset inset lines; center bar on that X. */ /* Center presets: midpoint between left- and right-preset inset lines; center bar on that X. */
@@ -55,25 +66,30 @@
#controls { #controls {
display: none; display: none;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 3px;
white-space: nowrap; white-space: nowrap;
overflow: visible; overflow: visible;
max-width: none; max-width: none;
} }
#controls > * + * { /* Standalone flash next to speed when N is pressed — hidden = no layout footprint */
margin-left: 3px;
}
/* Standalone flash indicator next to speed text — hidden by default,
briefly shown when nudge is toggled via N key or click */
#nudge-flash-indicator { #nudge-flash-indicator {
display: none; display: none;
margin: 0;
padding: 0;
border: 0;
width: 0;
min-width: 0;
max-width: 0;
height: 0;
min-height: 0;
overflow: hidden;
vertical-align: middle; vertical-align: middle;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-left: 0.3em;
padding: 3px 6px;
border-radius: 5px;
font-size: 14px; font-size: 14px;
line-height: 14px; line-height: 14px;
font-weight: bold; font-weight: bold;
@@ -81,8 +97,27 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* Same 24×24 footprint as #controls button */
#nudge-flash-indicator.visible { #nudge-flash-indicator.visible {
display: inline-flex; display: inline-flex;
box-sizing: border-box;
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
max-width: 24px;
max-height: 24px;
margin-left: 5px;
padding: 0;
border-width: 1px;
border-style: solid;
border-radius: 5px;
align-items: center;
justify-content: center;
font-size: 0;
line-height: 0;
overflow: hidden;
flex-shrink: 0;
} }
/* Hide flash indicator when hovering — the one in #controls is visible instead */ /* Hide flash indicator when hovering — the one in #controls is visible instead */
@@ -100,46 +135,75 @@
#nudge-flash-indicator[data-enabled="true"] { #nudge-flash-indicator[data-enabled="true"] {
color: #fff; color: #fff;
background: #4b9135; background: #4b9135;
border: 1px solid #6ec754; border-color: #6ec754;
} }
#nudge-flash-indicator[data-enabled="false"] { #nudge-flash-indicator[data-enabled="false"] {
color: #fff; color: #fff;
background: #943e3e; background: #943e3e;
border: 1px solid #c06060; border-color: #c06060;
} }
/* Same 24×24 chip as control buttons (Lucide check / x inside) */
#nudge-indicator { #nudge-indicator {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 3px 6px;
border-radius: 5px;
font-size: 14px;
line-height: 14px;
font-weight: bold;
font-family: "Lucida Console", Monaco, monospace;
box-sizing: border-box; box-sizing: border-box;
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
max-height: 24px;
padding: 0;
border-width: 1px;
border-style: solid;
border-radius: 5px;
font-size: 0;
line-height: 0;
cursor: pointer; cursor: pointer;
margin-bottom: 2px; margin: 0;
flex-shrink: 0;
overflow: hidden;
} }
#nudge-indicator[data-enabled="true"] { #nudge-indicator[data-enabled="true"] {
color: #fff; color: #fff;
background: #4b9135; background: #4b9135;
border: 1px solid #6ec754; border-color: #6ec754;
} }
#nudge-indicator[data-enabled="false"] { #nudge-indicator[data-enabled="false"] {
color: #fff; color: #fff;
background: #943e3e; background: #943e3e;
border: 1px solid #c06060; border-color: #c06060;
} }
#nudge-indicator[data-supported="false"] { #nudge-indicator[data-supported="false"] {
opacity: 0.6; opacity: 0.6;
} }
#nudge-flash-indicator.visible .vsc-btn-icon,
#nudge-indicator .vsc-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin: 0;
padding: 0;
line-height: 0;
}
#nudge-flash-indicator.visible .vsc-btn-icon svg,
#nudge-indicator .vsc-btn-icon svg {
display: block;
width: 100%;
height: 100%;
flex-shrink: 0;
transform: translateY(0.5px);
}
#controller.dragging { #controller.dragging {
cursor: -webkit-grabbing; cursor: -webkit-grabbing;
cursor: -moz-grabbing; cursor: -moz-grabbing;
@@ -148,12 +212,14 @@
} }
#controller.dragging #controls { #controller.dragging #controls {
display: inline; display: inline-flex;
vertical-align: middle;
} }
.draggable { .draggable {
cursor: -webkit-grab; cursor: -webkit-grab;
cursor: -moz-grab; cursor: -moz-grab;
vertical-align: middle;
} }
.draggable:active { .draggable:active {
@@ -175,6 +241,46 @@ button {
margin-bottom: 2px; margin-bottom: 2px;
} }
/* Icon buttons: square targets, compact bar (no extra vertical stretch). */
#controls button {
box-sizing: border-box;
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
max-height: 24px;
padding: 0;
margin: 0;
border-width: 1px;
line-height: 0;
font-size: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
button .vsc-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin: 0;
padding: 0;
line-height: 0;
}
button .vsc-btn-icon svg {
display: block;
width: 100%;
height: 100%;
flex-shrink: 0;
/* Lucide 24×24 paths sit slightly high in the viewBox */
transform: translateY(0.5px);
}
button:focus { button:focus {
outline: 0; outline: 0;
} }
+55
View File
@@ -0,0 +1,55 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.controllerUtils = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
var CONTROLLER_MARGIN_MAX_PX = 200;
var controllerLocations = [
"top-left",
"top-center",
"top-right",
"middle-right",
"bottom-right",
"bottom-center",
"bottom-left",
"middle-left"
];
var defaultControllerLocation = controllerLocations[0];
function normalizeControllerLocation(location, fallback) {
if (controllerLocations.includes(location)) return location;
return typeof fallback === "string"
? fallback
: defaultControllerLocation;
}
function clampControllerMarginPx(value, fallback) {
var numericValue = Number(value);
if (!Number.isFinite(numericValue)) return fallback;
return Math.min(
CONTROLLER_MARGIN_MAX_PX,
Math.max(0, Math.round(numericValue))
);
}
function getNextControllerLocation(location) {
var normalizedLocation = normalizeControllerLocation(location);
var currentIndex = controllerLocations.indexOf(normalizedLocation);
return controllerLocations[(currentIndex + 1) % controllerLocations.length];
}
return {
CONTROLLER_MARGIN_MAX_PX: CONTROLLER_MARGIN_MAX_PX,
clampControllerMarginPx: clampControllerMarginPx,
controllerLocations: controllerLocations.slice(),
defaultControllerLocation: defaultControllerLocation,
getNextControllerLocation: getNextControllerLocation,
normalizeControllerLocation: normalizeControllerLocation
};
});
+124
View File
@@ -0,0 +1,124 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.importExport = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
var rawSettingsKeys = new Set([
"audioBoolean",
"controllerButtons",
"controllerLocation",
"controllerMarginBottom",
"controllerMarginLeft",
"controllerMarginRight",
"controllerMarginTop",
"controllerOpacity",
"enableSubtitleNudge",
"enabled",
"forceLastSavedSpeed",
"hideWithControls",
"hideWithControlsTimer",
"hideWithYouTubeControls",
"keyBindings",
"lastSpeed",
"popupControllerButtons",
"popupMatchHoverControls",
"rememberSpeed",
"showPopupControlBar",
"siteRules",
"speed",
"startHidden",
"subtitleNudgeAmount",
"subtitleNudgeInterval"
]);
function isRecognizedRawSettingsObject(backup) {
if (!backup || typeof backup !== "object" || Array.isArray(backup)) {
return false;
}
return Object.keys(backup).some(function(key) {
return rawSettingsKeys.has(key);
});
}
function generateBackupFilename(now) {
var date = now instanceof Date ? now : new Date(now || Date.now());
var year = date.getFullYear();
var month = String(date.getMonth() + 1).padStart(2, "0");
var day = String(date.getDate()).padStart(2, "0");
var hours = String(date.getHours()).padStart(2, "0");
var minutes = String(date.getMinutes()).padStart(2, "0");
var seconds = String(date.getSeconds()).padStart(2, "0");
return (
"speeder-backup_" +
year +
"-" +
month +
"-" +
day +
"_" +
hours +
"." +
minutes +
"." +
seconds +
".json"
);
}
function buildBackupPayload(settings, localSettings, now) {
return {
version: "1.1",
exportDate: new Date(now || Date.now()).toISOString(),
settings: settings,
localSettings: localSettings || {}
};
}
function extractImportSettings(backup) {
var settingsToImport = null;
var isWrappedBackup = false;
if (backup && backup.settings && typeof backup.settings === "object") {
settingsToImport = backup.settings;
isWrappedBackup = true;
} else if (
backup &&
typeof backup === "object" &&
isRecognizedRawSettingsObject(backup)
) {
settingsToImport = backup;
}
if (!settingsToImport) return null;
return {
isWrappedBackup: isWrappedBackup,
settings: settingsToImport,
localSettings:
backup &&
backup.localSettings &&
typeof backup.localSettings === "object"
? backup.localSettings
: null
};
}
function parseImportText(text) {
return extractImportSettings(JSON.parse(text));
}
return {
buildBackupPayload: buildBackupPayload,
extractImportSettings: extractImportSettings,
generateBackupFilename: generateBackupFilename,
isRecognizedRawSettingsObject: isRecognizedRawSettingsObject,
parseImportText: parseImportText
};
});
+122
View File
@@ -0,0 +1,122 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.keyBindings = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
function normalizeBindingKey(key) {
if (typeof key !== "string" || key.length === 0) return null;
if (key === "Spacebar") return " ";
if (key === "Esc") return "Escape";
if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase();
return key;
}
function getLegacyKeyCode(binding) {
if (!binding) return null;
if (Number.isInteger(binding.keyCode)) return binding.keyCode;
if (typeof binding.key === "number" && Number.isInteger(binding.key)) {
return binding.key;
}
return null;
}
function legacyBindingKeyToCode(key) {
var normalizedKey = normalizeBindingKey(key);
if (!normalizedKey) return null;
if (/^[A-Z]$/.test(normalizedKey)) return "Key" + normalizedKey;
if (/^[0-9]$/.test(normalizedKey)) return "Digit" + normalizedKey;
if (/^F([1-9]|1[0-2])$/.test(normalizedKey)) return normalizedKey;
var keyMap = {
" ": "Space",
ArrowLeft: "ArrowLeft",
ArrowUp: "ArrowUp",
ArrowRight: "ArrowRight",
ArrowDown: "ArrowDown",
";": "Semicolon",
"<": "Comma",
"-": "Minus",
"+": "Equal",
">": "Period",
"/": "Slash",
"~": "Backquote",
"[": "BracketLeft",
"\\": "Backslash",
"]": "BracketRight",
"'": "Quote"
};
return keyMap[normalizedKey] || null;
}
function legacyKeyCodeToCode(keyCode) {
if (!Number.isInteger(keyCode)) return null;
if (keyCode >= 48 && keyCode <= 57) return "Digit" + String.fromCharCode(keyCode);
if (keyCode >= 65 && keyCode <= 90) return "Key" + String.fromCharCode(keyCode);
if (keyCode >= 96 && keyCode <= 105) return "Numpad" + (keyCode - 96);
if (keyCode >= 112 && keyCode <= 123) return "F" + (keyCode - 111);
var keyCodeMap = {
32: "Space",
37: "ArrowLeft",
38: "ArrowUp",
39: "ArrowRight",
40: "ArrowDown",
106: "NumpadMultiply",
107: "NumpadAdd",
109: "NumpadSubtract",
110: "NumpadDecimal",
111: "NumpadDivide",
186: "Semicolon",
188: "Comma",
189: "Minus",
187: "Equal",
190: "Period",
191: "Slash",
192: "Backquote",
219: "BracketLeft",
220: "Backslash",
221: "BracketRight",
222: "Quote",
59: "Semicolon",
61: "Equal",
173: "Minus"
};
return keyCodeMap[keyCode] || null;
}
function inferBindingCode(binding, fallbackCode) {
if (binding && typeof binding.code === "string" && binding.code.length > 0) {
return binding.code;
}
if (binding && typeof binding.key === "string") {
var codeFromKey = legacyBindingKeyToCode(binding.key);
if (codeFromKey) return codeFromKey;
}
var legacyKeyCode = getLegacyKeyCode(binding);
if (Number.isInteger(legacyKeyCode)) {
var codeFromKeyCode = legacyKeyCodeToCode(legacyKeyCode);
if (codeFromKeyCode) return codeFromKeyCode;
}
return typeof fallbackCode === "string" && fallbackCode.length > 0
? fallbackCode
: null;
}
return {
getLegacyKeyCode: getLegacyKeyCode,
inferBindingCode: inferBindingCode,
legacyBindingKeyToCode: legacyBindingKeyToCode,
legacyKeyCodeToCode: legacyKeyCodeToCode,
normalizeBindingKey: normalizeBindingKey
};
});
+85
View File
@@ -0,0 +1,85 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.popupControls = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
function normalizeExcludedIds(excludedIds) {
if (excludedIds instanceof Set) return excludedIds;
return new Set(Array.isArray(excludedIds) ? excludedIds : []);
}
function sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds) {
if (!Array.isArray(buttonIds)) return [];
var seen = new Set();
var excluded = normalizeExcludedIds(excludedIds);
return buttonIds.filter(function(id) {
if (!controllerButtonDefs[id] || excluded.has(id) || seen.has(id)) {
return false;
}
seen.add(id);
return true;
});
}
function resolvePopupButtons(storage, siteRule, options) {
var settings = storage || {};
var config = options || {};
var controllerButtonDefs = config.controllerButtonDefs || {};
var defaultButtons = Array.isArray(config.defaultButtons)
? config.defaultButtons
: [];
var excludedIds = config.excludedIds;
function sanitize(buttonIds) {
return sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds);
}
if (siteRule && Array.isArray(siteRule.popupControllerButtons)) {
return sanitize(siteRule.popupControllerButtons);
}
if (settings.popupMatchHoverControls) {
if (siteRule && Array.isArray(siteRule.controllerButtons)) {
return sanitize(siteRule.controllerButtons);
}
if (Array.isArray(settings.controllerButtons)) {
return sanitize(settings.controllerButtons);
}
}
if (Array.isArray(settings.popupControllerButtons)) {
return sanitize(settings.popupControllerButtons);
}
return sanitize(defaultButtons);
}
function pickBestFrameSpeedResult(results) {
if (!results || !results.length) return null;
var fallback = null;
for (var i = 0; i < results.length; i++) {
var result = results[i];
if (!result || typeof result.speed !== "number") continue;
if (result.preferred) return { speed: result.speed };
if (!fallback) fallback = { speed: result.speed };
}
return fallback;
}
return {
pickBestFrameSpeedResult: pickBestFrameSpeedResult,
resolvePopupButtons: resolvePopupButtons,
sanitizeButtonOrder: sanitizeButtonOrder
};
});
+69
View File
@@ -0,0 +1,69 @@
(function(root, factory) {
var exports = factory();
if (typeof module === "object" && module.exports) {
module.exports = exports;
}
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.siteRules = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm;
function escapeStringRegExp(str) {
return String(str).replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
}
function compileSiteRulePattern(pattern) {
if (typeof pattern !== "string") return null;
var normalizedPattern = pattern.replace(regStrip, "");
if (normalizedPattern.length === 0) return null;
if (
normalizedPattern.startsWith("/") &&
normalizedPattern.lastIndexOf("/") > 0
) {
var lastSlash = normalizedPattern.lastIndexOf("/");
return new RegExp(
normalizedPattern.substring(1, lastSlash),
normalizedPattern.substring(lastSlash + 1)
);
}
return new RegExp(escapeStringRegExp(normalizedPattern));
}
function matchSiteRule(url, siteRules) {
if (!url || !Array.isArray(siteRules)) return null;
for (var i = 0; i < siteRules.length; i++) {
var rule = siteRules[i];
if (!rule || !rule.pattern) continue;
try {
var re = compileSiteRulePattern(rule.pattern);
if (re && re.test(url)) {
return rule;
}
} catch (e) {
}
}
return null;
}
function isSiteRuleDisabled(rule) {
return Boolean(
rule &&
(rule.enabled === false || rule.disableExtension === true)
);
}
return {
compileSiteRulePattern: compileSiteRulePattern,
escapeStringRegExp: escapeStringRegExp,
isSiteRuleDisabled: isSiteRuleDisabled,
matchSiteRule: matchSiteRule
};
});
+164
View File
@@ -0,0 +1,164 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { vi } from "vitest";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", "..");
function readRepoFile(relPath) {
return fs.readFileSync(path.join(repoRoot, relPath), "utf8");
}
export function loadHtml(relPath) {
document.open();
document.write(readRepoFile(relPath));
document.close();
}
export function loadScript(relPath) {
window.eval(
"var chrome = window.chrome || globalThis.chrome;\n" +
readRepoFile(relPath) +
"\n//# sourceURL=" +
relPath
);
}
export async function flushAsyncWork() {
await Promise.resolve();
await Promise.resolve();
}
export function triggerDomContentLoaded() {
document.dispatchEvent(
new window.Event("DOMContentLoaded", {
bubbles: true,
cancelable: true
})
);
}
function createEvent() {
const listeners = [];
return {
addListener(listener) {
listeners.push(listener);
},
trigger(...args) {
listeners.forEach((listener) => listener(...args));
},
listeners
};
}
function createStorageArea(initialState = {}) {
const state = { ...initialState };
function resolveGet(keys) {
if (keys == null) return { ...state };
if (Array.isArray(keys)) {
return keys.reduce((acc, key) => {
if (Object.prototype.hasOwnProperty.call(state, key)) {
acc[key] = state[key];
}
return acc;
}, {});
}
if (typeof keys === "string") {
return Object.prototype.hasOwnProperty.call(state, keys)
? { [keys]: state[keys] }
: {};
}
if (typeof keys === "object") {
const result = { ...keys };
Object.keys(state).forEach((key) => {
result[key] = state[key];
});
return result;
}
return {};
}
return {
__state: state,
get: vi.fn((keys, callback) => {
callback(resolveGet(keys));
}),
set: vi.fn((items, callback) => {
Object.assign(state, items);
if (callback) callback();
}),
remove: vi.fn((keys, callback) => {
const list = Array.isArray(keys) ? keys : [keys];
list.forEach((key) => {
delete state[key];
});
if (callback) callback();
}),
clear: vi.fn((callback) => {
Object.keys(state).forEach((key) => delete state[key]);
if (callback) callback();
})
};
}
export function createChromeMock(options = {}) {
const syncArea = createStorageArea(options.sync ?? {});
const localArea = createStorageArea(options.local ?? {});
const tabsOnActivated = createEvent();
const tabsOnUpdated = createEvent();
const storageOnChanged = createEvent();
const chrome = {
runtime: {
lastError: null,
getManifest: vi.fn(() => ({
version: options.manifestVersion || "9.9.9"
})),
getURL: vi.fn((url) => "moz-extension://speeder/" + url)
},
storage: {
sync: syncArea,
local: localArea,
onChanged: storageOnChanged
},
tabs: {
query: vi.fn((queryInfo, callback) => {
callback(
options.tabs ??
[
{
id: 1,
active: true,
url: "https://example.com/watch"
}
]
);
}),
sendMessage: vi.fn((tabId, message, callback) => {
if (callback) {
callback(options.sendMessageResponse ?? { speed: 1.25 });
}
}),
executeScript: vi.fn((tabId, details, callback) => {
if (callback) {
callback(
options.executeScriptResponse ?? [
{ speed: 1.25, preferred: true }
]
);
}
}),
create: vi.fn(),
onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated
},
browserAction: {
setIcon: vi.fn()
}
};
return chrome;
}
+240
View File
@@ -0,0 +1,240 @@
const fs = require("fs");
const path = require("path");
const { vi } = require("vitest");
const ROOT = path.resolve(__dirname, "..", "..");
function clone(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function workspacePath(relPath) {
return path.join(ROOT, relPath);
}
function readWorkspaceFile(relPath) {
return fs.readFileSync(workspacePath(relPath), "utf8");
}
function loadHtmlFile(relPath) {
document.open();
document.write(readWorkspaceFile(relPath));
document.close();
}
function loadHtmlString(html) {
document.open();
document.write(html);
document.close();
}
function evaluateScript(relPath) {
const source = readWorkspaceFile(relPath);
window.eval(
`${source}\n//# sourceURL=${workspacePath(relPath).replace(/\\/g, "/")}`
);
}
function fireDOMContentLoaded() {
document.dispatchEvent(
new window.Event("DOMContentLoaded", {
bubbles: true,
cancelable: true
})
);
}
async function flushAsyncWork(turns) {
const count = turns || 2;
for (let i = 0; i < count; i += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function pickStorageValues(data, keys) {
if (keys == null) return clone(data || {});
if (typeof keys === "string") {
return { [keys]: clone(data ? data[keys] : undefined) };
}
if (Array.isArray(keys)) {
const result = {};
keys.forEach((key) => {
result[key] = clone(data ? data[key] : undefined);
});
return result;
}
if (typeof keys === "object") {
const result = clone(keys) || {};
Object.keys(keys).forEach((key) => {
if (data && Object.prototype.hasOwnProperty.call(data, key)) {
result[key] = clone(data[key]);
}
});
return result;
}
return {};
}
function createChromeEvent() {
const listeners = [];
return {
addListener(listener) {
listeners.push(listener);
},
removeListener(listener) {
const index = listeners.indexOf(listener);
if (index >= 0) {
listeners.splice(index, 1);
}
},
hasListener(listener) {
return listeners.includes(listener);
},
emit(...args) {
listeners.slice().forEach((listener) => listener(...args));
},
listeners
};
}
function createStorageArea(areaName, initialData, onChangedEvent) {
let data = clone(initialData) || {};
function emitChanges(changes) {
if (changes && Object.keys(changes).length > 0) {
onChangedEvent.emit(changes, areaName);
}
}
return {
get: vi.fn((keys, callback) => {
if (callback) callback(pickStorageValues(data, keys));
}),
set: vi.fn((items, callback) => {
const nextItems = items || {};
const changes = {};
Object.keys(nextItems).forEach((key) => {
const oldValue = clone(data[key]);
const newValue = clone(nextItems[key]);
data[key] = newValue;
changes[key] = { oldValue, newValue };
});
emitChanges(changes);
if (callback) callback();
}),
remove: vi.fn((keys, callback) => {
const list = Array.isArray(keys) ? keys : [keys];
const changes = {};
list.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(data, key)) {
changes[key] = {
oldValue: clone(data[key]),
newValue: undefined
};
delete data[key];
}
});
emitChanges(changes);
if (callback) callback();
}),
clear: vi.fn((callback) => {
const changes = {};
Object.keys(data).forEach((key) => {
changes[key] = {
oldValue: clone(data[key]),
newValue: undefined
};
});
data = {};
emitChanges(changes);
if (callback) callback();
}),
_dump() {
return clone(data);
}
};
}
function createChromeMock(options) {
const config = options || {};
const storageOnChanged = createChromeEvent();
const tabsOnActivated = createChromeEvent();
const tabsOnUpdated = createChromeEvent();
const runtimeOnMessage = createChromeEvent();
const chrome = {
runtime: {
lastError: null,
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
getURL: vi.fn((relPath) => `moz-extension://${relPath}`),
onMessage: runtimeOnMessage
},
browserAction: {
setIcon: vi.fn()
},
tabs: {
query: vi.fn((queryInfo, callback) => {
const tabs = clone(config.tabsQueryResult) || [
{ id: 1, active: true, url: "https://example.com/" }
];
if (callback) callback(tabs);
}),
sendMessage: vi.fn((tabId, message, callback) => {
if (callback) callback(null);
}),
executeScript: vi.fn((tabId, details, callback) => {
if (callback) callback([]);
}),
create: vi.fn(),
onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated
},
storage: {
onChanged: storageOnChanged,
sync: null,
local: null
}
};
chrome.storage.sync = createStorageArea(
"sync",
config.syncData,
storageOnChanged
);
chrome.storage.local = createStorageArea(
"local",
config.localData,
storageOnChanged
);
return chrome;
}
function installCommonWindowMocks() {
window.open = vi.fn();
window.close = vi.fn();
window.requestAnimationFrame = vi.fn((callback) => setTimeout(callback, 0));
window.cancelAnimationFrame = vi.fn((id) => clearTimeout(id));
}
module.exports = {
createChromeMock,
evaluateScript,
fireDOMContentLoaded,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlFile,
loadHtmlString,
readWorkspaceFile,
workspacePath
};
+276
View File
@@ -0,0 +1,276 @@
import {
createChromeMock,
flushAsyncWork,
loadHtml,
loadScript
} from "./helpers/browser.js";
async function setupImportExport(overrides = {}) {
loadHtml("options.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
globalThis.restore_options = vi.fn();
loadScript("shared/import-export.js");
loadScript("importExport.js");
await flushAsyncWork();
return globalThis.chrome;
}
describe("import/export flows", () => {
it("exports sync and local settings as a JSON download", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
const chrome = await setupImportExport({
sync: { rememberSpeed: true },
local: { customButtonIcons: { faster: { slug: "rocket" } } }
});
const OriginalBlob = globalThis.Blob;
globalThis.Blob = class TestBlob {
constructor(parts, options) {
this.parts = parts;
this.options = options;
}
async text() {
return this.parts.join("");
}
};
let capturedBlob = null;
let clickedDownload = null;
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn((blob) => {
capturedBlob = blob;
return "blob:test";
})
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(() => {})
});
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function () {
clickedDownload = this.download;
});
document.getElementById("exportSettings").click();
expect(clickedDownload).toBe("speeder-backup_2026-04-04_08.09.10.json");
expect(capturedBlob).not.toBeNull();
const blobText = await capturedBlob.text();
expect(JSON.parse(blobText)).toEqual({
version: "1.1",
exportDate: "2026-04-04T12:09:10.000Z",
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
});
expect(document.getElementById("status").textContent).toBe(
"Settings exported successfully"
);
expect(chrome.storage.sync.get).toHaveBeenCalled();
expect(chrome.storage.local.get).toHaveBeenCalled();
globalThis.Blob = OriginalBlob;
});
it("imports wrapped backup payloads and refreshes options", async () => {
vi.useFakeTimers();
const chrome = await setupImportExport();
const originalCreateElement = document.createElement.bind(document);
let createdInput = null;
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
const el = originalCreateElement(tagName);
if (tagName === "input") {
createdInput = el;
el.click = vi.fn();
}
return el;
});
globalThis.FileReader = class MockFileReader {
readAsText(file) {
this.onload({
target: {
result: file.__text
}
});
}
};
globalThis.importSettings();
createdInput.onchange({
target: {
files: [
{
__text: JSON.stringify({
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
})
}
]
}
});
expect(chrome.storage.local.set).toHaveBeenCalledWith(
{ customButtonIcons: { faster: { slug: "rocket" } } },
expect.any(Function)
);
expect(chrome.storage.sync.clear).toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true },
expect.any(Function)
);
expect(document.getElementById("status").textContent).toBe(
"Settings imported successfully. Reloading..."
);
vi.advanceTimersByTime(500);
expect(globalThis.restore_options).toHaveBeenCalled();
});
it("imports raw settings objects without touching local storage", async () => {
vi.useFakeTimers();
const chrome = await setupImportExport({
local: { customButtonIcons: { faster: { slug: "rocket" } } }
});
const originalCreateElement = document.createElement.bind(document);
let createdInput = null;
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
const el = originalCreateElement(tagName);
if (tagName === "input") {
createdInput = el;
el.click = vi.fn();
}
return el;
});
globalThis.FileReader = class MockFileReader {
readAsText(file) {
this.onload({
target: {
result: file.__text
}
});
}
};
globalThis.importSettings();
createdInput.onchange({
target: {
files: [
{
__text: JSON.stringify({
enabled: false,
siteRules: [{ pattern: "example.com", enabled: false }]
})
}
]
}
});
expect(chrome.storage.local.clear).not.toHaveBeenCalled();
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{
enabled: false,
siteRules: [{ pattern: "example.com", enabled: false }]
},
expect.any(Function)
);
});
it("clears stale local data when a wrapped backup has empty local settings", async () => {
vi.useFakeTimers();
const chrome = await setupImportExport({
local: {
customButtonIcons: { faster: { slug: "rocket" } },
lucideTagsCacheV1: { stale: true }
}
});
const originalCreateElement = document.createElement.bind(document);
let createdInput = null;
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
const el = originalCreateElement(tagName);
if (tagName === "input") {
createdInput = el;
el.click = vi.fn();
}
return el;
});
globalThis.FileReader = class MockFileReader {
readAsText(file) {
this.onload({
target: {
result: file.__text
}
});
}
};
globalThis.importSettings();
createdInput.onchange({
target: {
files: [
{
__text: JSON.stringify({
settings: { rememberSpeed: true },
localSettings: {}
})
}
]
}
});
expect(chrome.storage.local.clear).toHaveBeenCalled();
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true },
expect.any(Function)
);
});
it("shows an error for invalid backup files", async () => {
vi.useFakeTimers();
const chrome = await setupImportExport();
const originalCreateElement = document.createElement.bind(document);
let createdInput = null;
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
const el = originalCreateElement(tagName);
if (tagName === "input") {
createdInput = el;
el.click = vi.fn();
}
return el;
});
globalThis.FileReader = class MockFileReader {
readAsText(file) {
this.onload({
target: {
result: file.__text
}
});
}
};
globalThis.importSettings();
createdInput.onchange({
target: {
files: [
{
__text: JSON.stringify({ wat: true })
}
]
}
});
expect(document.getElementById("status").textContent).toBe(
"Error: Invalid backup file format"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
});
+189
View File
@@ -0,0 +1,189 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlString
} = require("./helpers/extension-test-utils");
function bootImportExport(options) {
const config = options || {};
loadHtmlString(`<!doctype html><html><body>
<button id="exportSettings">Export</button>
<button id="importSettings">Import</button>
<div id="status"></div>
</body></html>`);
installCommonWindowMocks();
const chrome = createChromeMock({
syncData: config.syncData,
localData: config.localData
});
global.chrome = chrome;
window.chrome = chrome;
const createObjectURL = vi.fn(() => "blob:test");
const revokeObjectURL = vi.fn();
vi.stubGlobal("URL", {
createObjectURL,
revokeObjectURL
});
evaluateScript("importExport.js");
return { chrome, createObjectURL, revokeObjectURL };
}
describe("importExport.js", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
});
it("generates timestamped backup filenames", () => {
vi.setSystemTime(new Date("2026-04-04T13:14:15Z"));
bootImportExport();
expect(window.generateBackupFilename()).toBe(
"speeder-backup_2026-04-04_13.14.15.json"
);
});
it("exports sync and local settings into a downloadable backup", async () => {
const clickSpy = vi
.spyOn(window.HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
const { createObjectURL, revokeObjectURL } = bootImportExport({
syncData: {
rememberSpeed: true,
keyBindings: [{ action: "faster", code: "KeyD", value: 0.1 }]
},
localData: {
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
}
});
document.querySelector("#exportSettings").click();
await flushAsyncWork();
expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0];
const backup = JSON.parse(await blob.text());
expect(backup.settings.rememberSpeed).toBe(true);
expect(backup.localSettings.customButtonIcons.faster.slug).toBe("rocket");
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test");
expect(document.querySelector("#status").textContent).toContain("exported");
});
it("imports wrapped backups, restores local data, and refreshes the options page", async () => {
const { chrome } = bootImportExport();
window.restore_options = vi.fn();
const realCreateElement = document.createElement.bind(document);
const fakeInput = realCreateElement("input");
Object.defineProperty(fakeInput, "files", {
configurable: true,
value: [
{
__contents: JSON.stringify({
settings: {
rememberSpeed: true,
enabled: false
},
localSettings: {
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
}
})
}
]
});
fakeInput.click = vi.fn(() => {
fakeInput.onchange({ target: fakeInput });
});
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (String(tagName).toLowerCase() === "input") {
return fakeInput;
}
return realCreateElement(tagName);
});
class FakeFileReader {
readAsText(file) {
this.onload({ target: { result: file.__contents } });
}
}
vi.stubGlobal("FileReader", FakeFileReader);
document.querySelector("#importSettings").click();
await flushAsyncWork();
expect(chrome.storage.local.set).toHaveBeenCalledWith(
{
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
},
expect.any(Function)
);
expect(chrome.storage.sync.clear).toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true, enabled: false },
expect.any(Function)
);
vi.advanceTimersByTime(500);
expect(window.restore_options).toHaveBeenCalled();
});
it("shows an error for malformed backups", async () => {
bootImportExport();
const realCreateElement = document.createElement.bind(document);
const fakeInput = realCreateElement("input");
Object.defineProperty(fakeInput, "files", {
configurable: true,
value: [{ __contents: "{bad json" }]
});
fakeInput.click = vi.fn(() => {
fakeInput.onchange({ target: fakeInput });
});
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (String(tagName).toLowerCase() === "input") {
return fakeInput;
}
return realCreateElement(tagName);
});
class FakeFileReader {
readAsText(file) {
this.onload({ target: { result: file.__contents } });
}
}
vi.stubGlobal("FileReader", FakeFileReader);
document.querySelector("#importSettings").click();
await flushAsyncWork();
expect(document.querySelector("#status").textContent).toContain(
"Failed to parse backup file"
);
});
});
+141
View File
@@ -0,0 +1,141 @@
const { afterEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
flushAsyncWork,
loadHtmlString
} = require("./helpers/extension-test-utils");
function bootInject(options) {
const config = options || {};
loadHtmlString("<!doctype html><html><body></body></html>");
const chrome = createChromeMock({
syncData: config.syncData,
localData: config.localData
});
global.chrome = chrome;
window.chrome = chrome;
window.requestIdleCallback = (callback, opts) =>
setTimeout(
() =>
callback({
didTimeout: false,
timeRemaining() {
return 1;
}
}),
(opts && opts.timeout) || 0
);
window.cancelIdleCallback = (id) => clearTimeout(id);
evaluateScript("ui-icons.js");
evaluateScript("inject.js");
return chrome;
}
describe("inject.js helper logic", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
});
it("normalizes bindings from legacy formats", async () => {
bootInject();
await flushAsyncWork(3);
expect(
window.normalizeStoredBinding({
action: "faster",
key: "g",
value: 1.8,
force: false
}).code
).toBe("KeyG");
expect(
window.normalizeStoredBinding({
action: "pause",
code: null,
key: null,
keyCode: null,
value: 0
})
).toEqual({
action: "pause",
code: null,
disabled: true,
value: 0,
force: "false",
predefined: false
});
expect(window.defaultKeyBindings({ speedStep: 0.25, rewindTime: 5 })[0]).toEqual(
{
action: "slower",
code: "KeyS",
value: 0.25,
force: false,
predefined: true
}
);
});
it("clamps controller margins and ignores stale source-specific target speeds", async () => {
bootInject();
await flushAsyncWork(3);
expect(window.normalizeControllerMarginPx(250, 0)).toBe(200);
expect(window.normalizeControllerMarginPx(-5, 65)).toBe(0);
expect(window.normalizeControllerMarginPx("bad", 65)).toBe(65);
const staleVideo = {
currentSrc: "fresh.mp4",
vsc: {
targetSpeed: 1.75,
targetSpeedSourceKey: "old.mp4"
}
};
expect(window.getControllerTargetSpeed(staleVideo)).toBeNull();
window.tc.settings.rememberSpeed = true;
window.tc.settings.forceLastSavedSpeed = false;
window.tc.settings.lastSpeed = 1.3;
window.tc.settings.speeds = { "fresh.mp4": 1.6 };
expect(window.getRememberedSpeed({ currentSrc: "fresh.mp4" })).toBe(1.6);
expect(window.getDesiredSpeed(staleVideo)).toBe(1.6);
});
it("applies site rule overrides and detects disabled sites", async () => {
bootInject();
await flushAsyncWork(3);
window.tc.settings.siteRules = [{ pattern: "localhost", enabled: false }];
window.captureSiteRuleBase();
expect(window.applySiteRuleOverrides()).toBe(true);
window.resetSettingsFromSiteRuleBase();
window.tc.settings.siteRules = [
{
pattern: "localhost",
controllerLocation: "bottom-left",
controllerMarginTop: 300,
controllerMarginBottom: -10,
rememberSpeed: true
}
];
window.captureSiteRuleBase();
expect(window.applySiteRuleOverrides()).toBe(false);
expect(window.tc.settings.controllerLocation).toBe("bottom-left");
expect(window.tc.settings.controllerMarginTop).toBe(200);
expect(window.tc.settings.controllerMarginBottom).toBe(0);
expect(window.tc.settings.rememberSpeed).toBe(true);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
import { createChromeMock, flushAsyncWork, loadScript } from "./helpers/browser.js";
function loadBlankDocument() {
document.open();
document.write("<!doctype html><html><body></body></html>");
document.close();
}
async function bootInject({ sync = {}, local = {} } = {}) {
loadBlankDocument();
globalThis.chrome = createChromeMock({ sync, local });
window.chrome = globalThis.chrome;
globalThis.chrome.runtime.onMessage = {
addListener: vi.fn()
};
const originalSyncGet = globalThis.chrome.storage.sync.get;
const originalLocalGet = globalThis.chrome.storage.local.get;
globalThis.chrome.storage.sync.get = vi.fn((keys, callback) => {
Promise.resolve().then(() => originalSyncGet(keys, callback));
});
globalThis.chrome.storage.local.get = vi.fn((keys, callback) => {
Promise.resolve().then(() => originalLocalGet(keys, callback));
});
globalThis.requestIdleCallback = (callback, options) =>
setTimeout(
() =>
callback({
didTimeout: false,
timeRemaining() {
return 1;
}
}),
(options && options.timeout) || 0
);
globalThis.cancelIdleCallback = (id) => clearTimeout(id);
loadScript("shared/controller-utils.js");
loadScript("shared/key-bindings.js");
loadScript("shared/site-rules.js");
loadScript("ui-icons.js");
loadScript("inject.js");
for (let i = 0; i < 3; i += 1) {
await flushAsyncWork();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
describe("inject runtime", () => {
it("keeps subtitle nudge disabled when the effective setting is off", async () => {
await bootInject({
sync: {
enableSubtitleNudge: false
}
});
const stopSubtitleNudge = vi.fn();
const startSubtitleNudge = vi.fn();
const flashEl = document.createElement("span");
const video = {
paused: false,
playbackRate: 1.5,
vsc: {
stopSubtitleNudge,
startSubtitleNudge,
subtitleNudgeEnabledOverride: null,
subtitleNudgeIndicator: null,
nudgeFlashIndicator: flashEl
}
};
expect(window.tc.settings.enableSubtitleNudge).toBe(false);
expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(false);
expect(window.setSubtitleNudgeEnabledForVideo(video, true)).toBe(false);
expect(video.vsc.subtitleNudgeEnabledOverride).toBeNull();
expect(stopSubtitleNudge).toHaveBeenCalledTimes(1);
expect(startSubtitleNudge).not.toHaveBeenCalled();
expect(flashEl.classList.contains("visible")).toBe(false);
window.tc.settings.enableSubtitleNudge = true;
expect(window.setSubtitleNudgeEnabledForVideo(video, true)).toBe(true);
expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(true);
window.tc.settings.enableSubtitleNudge = false;
expect(window.isSubtitleNudgeEnabledForVideo(video)).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 0));
});
});
+61
View File
@@ -0,0 +1,61 @@
const { afterEach, describe, expect, it } = require("vitest");
const {
evaluateScript,
loadHtmlString
} = require("./helpers/extension-test-utils");
describe("lucide-client.js", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("builds icon URLs and rejects invalid slugs", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
expect(window.lucideIconSvgUrl("alarm-clock")).toContain(
"/icons/alarm-clock.svg"
);
expect(window.lucideIconSvgUrl("bad slug!!")).toBe("");
expect(window.lucideTagsJsonUrl()).toContain("/tags.json");
});
it("sanitizes SVG before persisting a Lucide icon", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
const sanitized = window.sanitizeLucideSvg(`
<svg width="10" height="10" onclick="evil()">
<script>alert(1)</script>
<foreignObject>bad</foreignObject>
<path d="M0 0h10v10"></path>
</svg>
`);
expect(sanitized).toContain("<svg");
expect(sanitized).not.toContain("onclick");
expect(sanitized).not.toContain("<script");
expect(sanitized).not.toContain("foreignObject");
expect(sanitized).toContain('width="100%"');
});
it("searches and ranks icon slugs by query", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
const results = window.searchLucideSlugs(
{
alarm: ["clock", "time"],
"badge-alert": ["alert", "warning"],
calendar: ["date", "time"]
},
"al",
10
);
expect(results).toEqual(["alarm", "badge-alert", "calendar"]);
});
});
+194
View File
@@ -0,0 +1,194 @@
import {
createChromeMock,
flushAsyncWork,
loadHtml,
loadScript,
triggerDomContentLoaded
} from "./helpers/browser.js";
async function setupOptions(overrides = {}) {
loadHtml("options.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
globalThis.fetch = vi.fn();
loadScript("shared/controller-utils.js");
loadScript("shared/key-bindings.js");
loadScript("shared/popup-controls.js");
loadScript("ui-icons.js");
loadScript("lucide-client.js");
loadScript("options.js");
triggerDomContentLoaded();
await flushAsyncWork();
return globalThis.chrome;
}
describe("options page", () => {
it("restores stored settings, custom shortcuts, and site rules", async () => {
await setupOptions({
manifestVersion: "5.1.7.0",
sync: {
rememberSpeed: true,
enabled: false,
popupMatchHoverControls: false,
popupControllerButtons: ["rewind", "settings", "advance", "advance"],
keyBindings: [
{ action: "display", code: "KeyV", value: 0, predefined: true },
{ action: "pause", code: "KeyQ", value: 0, predefined: false }
],
siteRules: [
{
pattern: "youtube.com",
enabled: true,
showPopupControlBar: false,
popupControllerButtons: ["advance", "settings", "advance"]
}
]
}
});
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
expect(document.getElementById("rememberSpeed").checked).toBe(true);
expect(document.getElementById("enabled").checked).toBe(false);
expect(document.querySelector('.shortcut-row[data-action="pause"]')).not.toBe(
null
);
expect(document.getElementById("siteRulesContainer").children.length).toBe(
1
);
expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]);
});
it("validates site rule regexes before saving", async () => {
const chrome = await setupOptions();
chrome.storage.sync.set.mockClear();
globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule");
rule.querySelector(".site-pattern").value = "/(/";
globalThis.save_options();
expect(document.getElementById("status").textContent).toContain(
"Invalid site rule regex"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
it("shows a more-menu trigger for collapsed site rules and a collapse trigger when open", async () => {
await setupOptions({ sync: { siteRules: [] } });
globalThis.createSiteRule({ pattern: "youtube.com" });
const rule = document.getElementById("siteRulesContainer").lastElementChild;
const toggle = rule.querySelector(".toggle-site-rule");
const body = rule.querySelector(".site-rule-body");
expect(rule.classList.contains("collapsed")).toBe(true);
expect(body.style.display).toBe("none");
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(toggle.getAttribute("aria-label")).toBe("Expand site rule");
expect(toggle.querySelector("svg")).not.toBeNull();
globalThis.setSiteRuleExpandedState(rule, true);
expect(rule.classList.contains("collapsed")).toBe(false);
expect(body.style.display).toBe("block");
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(toggle.getAttribute("aria-label")).toBe("Collapse site rule");
});
it("keeps site override settings visible but disabled until enabled", async () => {
await setupOptions({ sync: { siteRules: [] } });
globalThis.createSiteRule({ pattern: "youtube.com" });
const rule = document.getElementById("siteRulesContainer").lastElementChild;
const playbackOverride = rule.querySelector(".override-playback");
const playbackContainer = rule.querySelector(".site-playback-container");
const rememberSpeed = rule.querySelector(".site-rememberSpeed");
expect(playbackContainer.classList.contains("site-override-disabled")).toBe(
true
);
expect(rememberSpeed.disabled).toBe(true);
playbackOverride.checked = true;
playbackOverride.dispatchEvent(
new Event("change", {
bubbles: true
})
);
expect(playbackContainer.classList.contains("site-override-disabled")).toBe(
false
);
expect(rememberSpeed.disabled).toBe(false);
});
it("saves normalized settings and sanitized popup/site-rule controls", async () => {
const chrome = await setupOptions();
document.getElementById("rememberSpeed").checked = true;
document.getElementById("hideWithControlsTimer").value = "20";
document.getElementById("controllerOpacity").value = "0";
document.getElementById("controllerMarginTop").value = "250";
document.getElementById("controllerMarginBottom").value = "-4";
document.getElementById("enableSubtitleNudge").checked = true;
document.getElementById("subtitleNudgeInterval").value = "5";
document.getElementById("popupMatchHoverControls").checked = false;
document.getElementById("showPopupControlBar").checked = false;
globalThis.populatePopupControlBarEditor([
"rewind",
"settings",
"faster",
"faster"
]);
globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule");
rule.querySelector(".site-pattern").value = "youtube.com";
rule.querySelector(".override-playback").checked = true;
rule.querySelector(".site-rememberSpeed").checked = true;
rule.querySelector(".override-opacity").checked = true;
rule.querySelector(".site-controllerOpacity").value = "0";
rule.querySelector(".override-popup-controlbar").checked = true;
rule.querySelector(".site-showPopupControlBar").checked = false;
globalThis.populateControlBarZones(
rule.querySelector(".site-popup-cb-active"),
rule.querySelector(".site-popup-cb-available"),
["advance", "settings", "advance"],
function (id) {
return id !== "settings";
}
);
globalThis.save_options();
expect(chrome.storage.sync.remove).toHaveBeenCalled();
const savedSettings =
chrome.storage.sync.set.mock.calls[
chrome.storage.sync.set.mock.calls.length - 1
][0];
expect(savedSettings.rememberSpeed).toBe(true);
expect(savedSettings.hideWithControlsTimer).toBe(15);
expect(savedSettings.controllerOpacity).toBe(0);
expect(savedSettings.controllerMarginTop).toBe(200);
expect(savedSettings.controllerMarginBottom).toBe(0);
expect(savedSettings.subtitleNudgeInterval).toBe(10);
expect(savedSettings.showPopupControlBar).toBe(false);
expect(savedSettings.popupMatchHoverControls).toBe(false);
expect(savedSettings.popupControllerButtons).toEqual(["rewind", "faster"]);
expect(savedSettings.siteRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
pattern: "youtube.com",
rememberSpeed: true,
controllerOpacity: 0,
showPopupControlBar: false,
popupControllerButtons: ["advance"]
})
])
);
});
});
+230
View File
@@ -0,0 +1,230 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
fireDOMContentLoaded,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlFile
} = require("./helpers/extension-test-utils");
function bootOptions(options) {
const config = options || {};
loadHtmlFile("options.html");
installCommonWindowMocks();
const chrome = createChromeMock({
manifest: { version: "5.1.7.0" },
syncData: config.syncData,
localData: config.localData
});
global.chrome = chrome;
window.chrome = chrome;
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
text: () => Promise.resolve("<svg></svg>")
})
);
window.fetch = global.fetch;
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
evaluateScript("options.js");
fireDOMContentLoaded();
return chrome;
}
describe("options.js", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
delete global.fetch;
});
it("restores saved settings, bindings, site rules, and popup bar order", async () => {
bootOptions({
syncData: {
rememberSpeed: true,
forceLastSavedSpeed: true,
controllerLocation: "middle-right",
controllerOpacity: 0.75,
controllerMarginTop: 22,
controllerMarginBottom: 14,
popupMatchHoverControls: false,
controllerButtons: ["rewind", "fast", "display"],
popupControllerButtons: ["advance", "settings", "rewind", "advance"],
keyBindings: [
{ action: "display", code: "KeyV", value: 0, predefined: true },
{ action: "pause", code: "KeyQ", value: 0, predefined: false }
],
siteRules: [
{
pattern: "youtube.com",
enabled: false,
controllerMarginTop: 12,
popupControllerButtons: ["advance", "settings", "rewind"]
}
]
}
});
await flushAsyncWork(3);
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
expect(document.getElementById("rememberSpeed").checked).toBe(true);
expect(document.getElementById("forceLastSavedSpeed").checked).toBe(true);
expect(document.getElementById("controllerLocation").value).toBe(
"middle-right"
);
expect(document.getElementById("controllerOpacity").value).toBe("0.75");
expect(document.getElementById("controllerMarginTop").value).toBe("22");
expect(document.getElementById("popupMatchHoverControls").checked).toBe(false);
expect(
document.getElementById("popupCbEditorWrap").classList.contains(
"cb-editor-disabled"
)
).toBe(false);
const popupButtons = Array.from(
document.querySelectorAll("#popupControlBarActive .cb-block")
).map((block) => block.dataset.buttonId);
expect(popupButtons).toEqual(["advance", "rewind"]);
expect(
document.querySelector('.shortcut-row.customs[data-action="pause"]')
).not.toBeNull();
expect(document.querySelectorAll(".site-rule")).toHaveLength(1);
expect(document.querySelector(".site-rule .site-enabled").checked).toBe(false);
});
it("saves normalized settings and site rule overrides", async () => {
const chrome = bootOptions();
await flushAsyncWork(3);
document.getElementById("rememberSpeed").checked = true;
document.getElementById("hideWithControlsTimer").value = "99";
document.getElementById("controllerLocation").value = "bottom-left";
document.getElementById("controllerOpacity").value = "0.65";
document.getElementById("controllerMarginTop").value = "250";
document.getElementById("controllerMarginBottom").value = "-5";
document.getElementById("popupMatchHoverControls").checked = false;
document.getElementById("showPopupControlBar").checked = true;
window.populatePopupControlBarEditor(["advance", "settings", "rewind"]);
window.createSiteRule({ pattern: "youtube.com" });
const ruleEl = document.querySelector(".site-rule");
ruleEl.querySelector(".override-placement").checked = true;
ruleEl.querySelector(".site-controllerLocation").value = "top-right";
ruleEl.querySelector(".site-controllerMarginTop").value = "300";
ruleEl.querySelector(".site-controllerMarginBottom").value = "-10";
ruleEl.querySelector(".override-autohide").checked = true;
ruleEl.querySelector(".site-hideWithControls").checked = true;
ruleEl.querySelector(".site-hideWithControlsTimer").value = "0";
ruleEl.querySelector(".override-popup-controlbar").checked = true;
ruleEl.querySelector(".site-showPopupControlBar").checked = false;
window.populateControlBarZones(
ruleEl.querySelector(".site-popup-cb-active"),
ruleEl.querySelector(".site-popup-cb-available"),
["advance", "settings", "rewind"],
function (id) {
return id !== "settings";
}
);
window.save_options();
expect(chrome.storage.sync.remove).toHaveBeenCalledWith(
[
"resetSpeed",
"speedStep",
"fastSpeed",
"rewindTime",
"advanceTime",
"resetKeyCode",
"slowerKeyCode",
"fasterKeyCode",
"rewindKeyCode",
"advanceKeyCode",
"fastKeyCode",
"blacklist"
],
expect.any(Function)
);
const savedSettings = chrome.storage.sync.set.mock.calls.at(-1)[0];
expect(savedSettings.rememberSpeed).toBe(true);
expect(savedSettings.hideWithControlsTimer).toBe(15);
expect(savedSettings.controllerLocation).toBe("bottom-left");
expect(savedSettings.controllerMarginTop).toBe(200);
expect(savedSettings.controllerMarginBottom).toBe(0);
expect(savedSettings.popupControllerButtons).toEqual(["advance", "rewind"]);
expect(savedSettings.siteRules).toEqual([
{
pattern: "youtube.com",
enabled: true,
controllerLocation: "top-right",
controllerMarginTop: 200,
controllerMarginBottom: 0,
hideWithControls: true,
hideWithControlsTimer: 0.1,
showPopupControlBar: false,
popupControllerButtons: ["advance", "rewind"]
}
]);
});
it("blocks save when a site rule regex is invalid", async () => {
const chrome = bootOptions();
await flushAsyncWork(3);
window.createSiteRule({ pattern: "/[abc/" });
window.save_options();
expect(document.getElementById("status").textContent).toContain(
"Invalid site rule regex"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
it("adds shortcuts from the selector and records key input states", async () => {
bootOptions();
await flushAsyncWork(3);
const selector = document.getElementById("addShortcutSelector");
selector.value = "pause";
selector.dispatchEvent(new window.Event("change", { bubbles: true }));
const row = document.querySelector('.shortcut-row.customs[data-action="pause"]');
expect(row).not.toBeNull();
const keyInput = row.querySelector(".customKey");
keyInput.dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "q",
code: "KeyQ",
bubbles: true
})
);
expect(keyInput.vscBinding.code).toBe("KeyQ");
expect(keyInput.value).toBe("Q");
keyInput.dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
bubbles: true
})
);
expect(keyInput.vscBinding.disabled).toBe(true);
expect(selector.disabled).toBe(false);
});
});
+121
View File
@@ -0,0 +1,121 @@
import {
createChromeMock,
flushAsyncWork,
loadHtml,
loadScript,
triggerDomContentLoaded
} from "./helpers/browser.js";
async function setupPopup(overrides = {}) {
loadHtml("popup.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
loadScript("shared/site-rules.js");
loadScript("shared/popup-controls.js");
loadScript("ui-icons.js");
loadScript("popup.js");
triggerDomContentLoaded();
await flushAsyncWork();
return globalThis.chrome;
}
describe("popup UI", () => {
it("renders version, builds controls, and prefers the active frame speed", async () => {
await setupPopup({
manifestVersion: "5.1.7.0",
executeScriptResponse: [
{ speed: 1.1, preferred: false },
{ speed: 1.75, preferred: true }
]
});
expect(document.getElementById("app-version").innerText).toBe("5.1.7.0");
expect(document.getElementById("popupSpeed").textContent).toBe("1.75");
expect(
document.querySelectorAll("#popupControlBar button").length
).toBeGreaterThan(0);
});
it("shows disabled state for a matching site rule", async () => {
await setupPopup({
sync: {
enabled: true,
siteRules: [{ pattern: "example.com", enabled: false }]
}
});
expect(document.getElementById("status").innerText).toBe(
"Speeder is disabled for this site."
);
expect(document.getElementById("popupSpeed").textContent).toBe("1.00");
expect(document.getElementById("popupControlBar").style.display).toBe(
"none"
);
});
it("toggles enabled state and updates the browser action icons", async () => {
const chrome = await setupPopup();
chrome.storage.sync.set.mockClear();
chrome.browserAction.setIcon.mockClear();
document.getElementById("disable").click();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ enabled: false },
expect.any(Function)
);
expect(document.getElementById("enable").classList.contains("hide")).toBe(
false
);
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
path: {
19: "icons/icon19_disabled.png",
38: "icons/icon38_disabled.png",
48: "icons/icon48_disabled.png"
}
});
});
it("handles refresh responses for unsupported and successful pages", async () => {
vi.useFakeTimers();
const chrome = await setupPopup();
let response = null;
chrome.tabs.sendMessage.mockImplementation((tabId, message, callback) => {
if (message.action === "rescan_page") {
callback(response);
return;
}
callback({ speed: 1.25 });
});
document.getElementById("refresh").click();
expect(document.getElementById("status").innerText).toBe(
"Cannot run on this page."
);
response = { status: "complete" };
document.getElementById("refresh").click();
expect(document.getElementById("status").innerText).toBe(
"Scan complete. Closing..."
);
vi.advanceTimersByTime(500);
expect(window.close).toHaveBeenCalled();
});
it("dispatches popup control bar actions back to the active tab", async () => {
const chrome = await setupPopup();
chrome.tabs.sendMessage.mockClear();
chrome.tabs.executeScript.mockClear();
document.querySelector("#popupControlBar button").click();
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
1,
expect.objectContaining({
action: "run_action"
}),
expect.any(Function)
);
expect(chrome.tabs.executeScript).toHaveBeenCalled();
});
});
+173
View File
@@ -0,0 +1,173 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
fireDOMContentLoaded,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlFile
} = require("./helpers/extension-test-utils");
function bootPopup(options) {
const config = options || {};
loadHtmlFile("popup.html");
installCommonWindowMocks();
const chrome = createChromeMock({
manifest: { version: "9.9.9-test" },
syncData: config.syncData,
localData: config.localData,
tabsQueryResult: [
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
]
});
chrome.tabs.executeScript.mockImplementation(
config.executeScriptImpl ||
((tabId, details, callback) => {
if (callback) callback([{ speed: 1.0, preferred: true }]);
})
);
chrome.tabs.sendMessage.mockImplementation(
config.sendMessageImpl ||
((tabId, message, callback) => {
if (message.action === "get_speed") {
callback({ speed: 1.0 });
return;
}
if (message.action === "rescan_page") {
callback({ status: "complete" });
return;
}
callback({ speed: 1.0 });
})
);
global.chrome = chrome;
window.chrome = chrome;
evaluateScript("ui-icons.js");
evaluateScript("popup.js");
fireDOMContentLoaded();
return chrome;
}
describe("popup.js", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
});
it("renders the popup disabled state when a site rule disables Speeder", async () => {
bootPopup({
syncData: {
enabled: true,
siteRules: [
{
pattern: "youtube.com",
enabled: false
}
]
},
activeTab: {
id: 10,
active: true,
url: "https://www.youtube.com/watch?v=abc123"
}
});
await flushAsyncWork();
expect(document.querySelector("#app-version").textContent).toBe("9.9.9-test");
expect(document.querySelector("#status").textContent).toContain(
"disabled for this site"
);
expect(document.querySelector("#popupSpeed").textContent).toBe("1.00");
expect(document.querySelector("#popupControlBar").style.display).toBe("none");
});
it("builds sanitized popup buttons and refreshes speed after an action", async () => {
const chrome = bootPopup({
syncData: {
enabled: true,
controllerButtons: ["faster", "settings", "rewind", "faster"],
popupMatchHoverControls: true
}
});
chrome.tabs.executeScript
.mockImplementationOnce((tabId, details, callback) => {
callback([
{ speed: 1.25, preferred: false },
{ speed: 1.5, preferred: true }
]);
})
.mockImplementationOnce((tabId, details, callback) => {
callback([{ speed: 1.75, preferred: true }]);
});
chrome.tabs.sendMessage.mockImplementation((tabId, message, callback) => {
if (message.action === "run_action") {
callback({ speed: 1.75 });
return;
}
callback({ speed: 1.0 });
});
document.dispatchEvent(new window.Event("DOMContentLoaded"));
await flushAsyncWork();
const buttons = Array.from(
document.querySelectorAll("#popupControlBar button[data-action]")
).map((button) => button.dataset.action);
expect(buttons).toEqual(["faster", "rewind"]);
expect(document.querySelector("#popupSpeed").textContent).toBe("1.50");
document.querySelector('#popupControlBar button[data-action="faster"]').click();
await flushAsyncWork();
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
99,
{ action: "run_action", actionName: "faster" },
expect.any(Function)
);
expect(document.querySelector("#popupSpeed").textContent).toBe("1.75");
});
it("toggles enablement and closes after a successful refresh", async () => {
vi.useFakeTimers();
const chrome = bootPopup({
syncData: {
enabled: false
}
});
await flushAsyncWork();
expect(document.querySelector("#enable").classList.contains("hide")).toBe(false);
expect(document.querySelector("#disable").classList.contains("hide")).toBe(true);
document.querySelector("#enable").click();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ enabled: true },
expect.any(Function)
);
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
path: {
19: "icons/icon19.png",
38: "icons/icon38.png",
48: "icons/icon48.png"
}
});
document.querySelector("#refresh").click();
expect(document.querySelector("#status").textContent).toContain("Closing");
vi.advanceTimersByTime(500);
expect(window.close).toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { afterEach, beforeEach, vi } from "vitest";
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
if (typeof window !== "undefined") {
window.open = vi.fn();
window.close = vi.fn();
}
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
});
afterEach(() => {
vi.useRealTimers();
delete globalThis.SpeederShared;
delete globalThis.restore_options;
if (typeof document !== "undefined") {
document.head.innerHTML = "";
document.body.innerHTML = "";
}
delete globalThis.chrome;
});
+153
View File
@@ -0,0 +1,153 @@
import controllerUtils from "../shared/controller-utils.js";
import importExportUtils from "../shared/import-export.js";
import keyBindingUtils from "../shared/key-bindings.js";
import popupControls from "../shared/popup-controls.js";
import siteRules from "../shared/site-rules.js";
describe("shared helpers", () => {
it("matches site rules and skips invalid regex patterns", () => {
const literalRule = { pattern: "example.com/watch" };
const regexRule = { pattern: "/youtube\\.com\\/watch/i" };
expect(
siteRules.matchSiteRule("https://example.com/watch?v=1", [literalRule])
).toBe(literalRule);
expect(
siteRules.matchSiteRule("https://www.youtube.com/watch?v=2", [regexRule])
).toBe(regexRule);
expect(
siteRules.matchSiteRule("https://www.youtube.com/shorts/3", [
{ pattern: "/(/" },
regexRule
])
).toBeNull();
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
});
it("sanitizes and resolves popup button orders", () => {
const controllerButtonDefs = {
rewind: {},
faster: {},
advance: {},
display: {},
settings: {}
};
expect(
popupControls.sanitizeButtonOrder(
["rewind", "settings", "rewind", "faster", "missing"],
controllerButtonDefs,
new Set(["settings"])
)
).toEqual(["rewind", "faster"]);
expect(
popupControls.resolvePopupButtons(
{
popupMatchHoverControls: true,
controllerButtons: ["advance", "display"],
popupControllerButtons: ["rewind"]
},
{ controllerButtons: ["faster", "advance"] },
{
controllerButtonDefs,
defaultButtons: ["rewind", "display"],
excludedIds: ["settings"]
}
)
).toEqual(["faster", "advance"]);
expect(
popupControls.resolvePopupButtons(
{
popupMatchHoverControls: false,
popupControllerButtons: ["rewind", "display"]
},
{ popupControllerButtons: ["advance", "settings", "advance"] },
{
controllerButtonDefs,
defaultButtons: ["rewind", "display"],
excludedIds: ["settings"]
}
)
).toEqual(["advance"]);
});
it("normalizes controller locations and margins", () => {
expect(controllerUtils.normalizeControllerLocation("top-right")).toBe(
"top-right"
);
expect(controllerUtils.normalizeControllerLocation("bogus")).toBe(
controllerUtils.defaultControllerLocation
);
expect(controllerUtils.clampControllerMarginPx(300, 65)).toBe(200);
expect(controllerUtils.clampControllerMarginPx(-5, 65)).toBe(0);
expect(controllerUtils.getNextControllerLocation("top-left")).toBe(
"top-center"
);
});
it("infers key binding codes from legacy formats", () => {
expect(keyBindingUtils.normalizeBindingKey("a")).toBe("A");
expect(keyBindingUtils.normalizeBindingKey("Esc")).toBe("Escape");
expect(keyBindingUtils.legacyBindingKeyToCode(" ")).toBe("Space");
expect(keyBindingUtils.legacyKeyCodeToCode(90)).toBe("KeyZ");
expect(keyBindingUtils.inferBindingCode({ key: "x" }, null)).toBe("KeyX");
expect(keyBindingUtils.inferBindingCode({ keyCode: 107 }, null)).toBe(
"NumpadAdd"
);
expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65);
});
it("builds and parses import/export payloads", () => {
expect(
importExportUtils.generateBackupFilename(new Date(2026, 3, 4, 8, 9, 10))
).toBe("speeder-backup_2026-04-04_08.09.10.json");
expect(
importExportUtils.buildBackupPayload(
{ rememberSpeed: true },
{ customButtonIcons: {} },
"2026-04-04T08:09:10Z"
)
).toEqual({
version: "1.1",
exportDate: "2026-04-04T08:09:10.000Z",
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: {} }
});
expect(
importExportUtils.extractImportSettings({
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: {} }
})
).toEqual({
isWrappedBackup: true,
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: {} }
});
expect(
importExportUtils.parseImportText(
JSON.stringify({ rememberSpeed: true, keyBindings: [] })
)
).toEqual({
isWrappedBackup: false,
settings: { rememberSpeed: true, keyBindings: [] },
localSettings: null
});
expect(
importExportUtils.extractImportSettings({ enabled: true })
).toEqual({
isWrappedBackup: false,
settings: { enabled: true },
localSettings: null
});
expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe(
false
);
});
});
+143
View File
@@ -0,0 +1,143 @@
/**
* Inline SVG icons (Lucide-style strokes, compatible with https://lucide.dev — ISC license).
* Use stroke="currentColor" so buttons inherit foreground for monochrome UI.
*/
var VSC_ICON_SIZE_DEFAULT = 18;
var VSC_SVG_NS = "http://www.w3.org/2000/svg";
/** Inner SVG markup only (paths / shapes inside <svg>). */
var vscUiIconPaths = {
rewind:
'<polygon points="11 19 2 12 11 5 11 19"/><polygon points="22 19 13 12 22 5 22 19"/>',
advance:
'<polygon points="13 19 22 12 13 5 13 19"/><polygon points="2 19 11 12 2 5 2 19"/>',
reset:
'<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
slower: '<line x1="5" y1="12" x2="19" y2="12"/>',
faster:
'<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
moreHorizontal:
'<circle cx="6" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="18" cy="12" r="1.5"/>',
chevronUp: '<path d="m18 15-6-6-6 6"/>',
display:
'<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
fast: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
settings:
'<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
pause:
'<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>',
muted:
'<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>',
mark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>',
jump:
'<polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/>',
nudge: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
/** Lucide check — subtitle nudge on */
subtitleNudgeOn: '<path d="M20 6 9 17l-5-5"/>',
/** Lucide x — subtitle nudge off */
subtitleNudgeOff:
'<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'
};
/**
* @param {number} [size] - width/height in px
* @returns {string} full <svg>…</svg>
*/
function vscIconSvgString(action, size) {
var inner = vscUiIconPaths[action];
if (!inner) return "";
var s = size != null ? size : VSC_ICON_SIZE_DEFAULT;
return (
'<svg xmlns="http://www.w3.org/2000/svg" width="' +
s +
'" height="' +
s +
'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
inner +
"</svg>"
);
}
function vscClearElement(el) {
if (!el) return;
while (el.firstChild) {
el.removeChild(el.firstChild);
}
}
function vscSanitizeSvgTree(svg) {
if (!svg || String(svg.tagName).toLowerCase() !== "svg") return null;
svg.querySelectorAll("script, style, foreignObject").forEach(function (n) {
n.remove();
});
svg.querySelectorAll("*").forEach(function (el) {
for (var i = el.attributes.length - 1; i >= 0; i--) {
var attr = el.attributes[i];
var name = attr.name.toLowerCase();
var val = attr.value;
if (name.indexOf("on") === 0) {
el.removeAttribute(attr.name);
continue;
}
if (
(name === "href" || name === "xlink:href") &&
/^\s*javascript:/i.test(val)
) {
el.removeAttribute(attr.name);
}
}
});
svg.setAttribute("xmlns", VSC_SVG_NS);
svg.setAttribute("aria-hidden", "true");
return svg;
}
function vscCreateSvgNode(doc, svgText) {
if (!doc || !svgText || typeof svgText !== "string") return null;
var clean = String(svgText).replace(/\0/g, "").trim();
if (!clean || !/<svg[\s>]/i.test(clean)) return null;
var parsed = new DOMParser().parseFromString(clean, "image/svg+xml");
if (parsed.querySelector("parsererror")) return null;
var svg = vscSanitizeSvgTree(parsed.querySelector("svg"));
if (!svg) return null;
return doc.importNode(svg, true);
}
function vscSetSvgContent(el, svgText) {
if (!el) return false;
vscClearElement(el);
var doc = el.ownerDocument || document;
var svg = vscCreateSvgNode(doc, svgText);
if (!svg) return false;
el.appendChild(svg);
return true;
}
function vscCreateSvgWrap(doc, svgText, className) {
if (!doc) return null;
var span = doc.createElement("span");
span.className = className || "vsc-btn-icon";
if (!vscSetSvgContent(span, svgText)) {
return null;
}
return span;
}
/**
* @param {Document} doc
* @param {string} action
* @returns {HTMLElement|null} wrapper span containing svg, or null if no icon
*/
function vscIconWrap(doc, action, size) {
var html = vscIconSvgString(action, size);
if (!html) return null;
return vscCreateSvgWrap(doc, html, "vsc-btn-icon");
}
+12
View File
@@ -0,0 +1,12 @@
const { defineConfig } = require("vitest/config");
module.exports = defineConfig({
test: {
environment: "jsdom",
clearMocks: true,
globals: true,
restoreMocks: true,
include: ["tests/**/*.test.js"],
setupFiles: ["./tests/setup.js"]
}
});