diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 5480842..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "kiroAgent.configureMCP": "Disabled"
-}
\ No newline at end of file
diff --git a/README.md b/README.md
index a3f5c7d..a0559aa 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
# Available for Firefox
-[](https://addons.mozilla.org//firefox/addon/speeder/)
+[](https://addons.mozilla.org/firefox/addon/speeder/)
# The science of accelerated playback
@@ -33,7 +33,7 @@ last point to listen to it a few more times.

-#### *Install [Chrome](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk) or [Firefox](https://addons.mozilla.org/en-us/firefox/addon/videospeed/) Extension*
+#### *Install [Chrome](https://chrome.google.com/webstore/detail/video-speed-controller/nffaoalbilbmmfgbnbgppjihopabppdk) or [Firefox](https://addons.mozilla.org/en-us/firefox/addon/speeder/) Extension*
\*\* Once the extension is installed simply navigate to any page that offers
HTML5 video ([example](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
and click "Add New".
-
+
Some sites may assign other functionality to one of the assigned shortcut keys —
these collisions are inevitable, unfortunately. As a workaround, the extension
diff --git a/importExport.js b/importExport.js
index dff6cb4..19b9f4f 100644
--- a/importExport.js
+++ b/importExport.js
@@ -1,129 +1,35 @@
// Import/Export functionality for Video Speed Controller settings
-
-const EXPORTABLE_LOCAL_SETTINGS_KEYS = ["customButtonIcons"];
-
-function getExportableLocalSettings(localStorage) {
- const exportable = {};
- const customButtonIcons =
- localStorage &&
- localStorage.customButtonIcons &&
- typeof localStorage.customButtonIcons === "object" &&
- !Array.isArray(localStorage.customButtonIcons)
- ? localStorage.customButtonIcons
- : null;
-
- if (customButtonIcons) {
- exportable.customButtonIcons = customButtonIcons;
- }
-
- return exportable;
-}
-
-function replaceImportableLocalSettings(localSettings, callback) {
- chrome.storage.local.remove(EXPORTABLE_LOCAL_SETTINGS_KEYS, function () {
- if (chrome.runtime.lastError) {
- showStatus(
- "Error: Failed to clear local icon overrides - " +
- chrome.runtime.lastError.message,
- true
- );
- return;
- }
-
- if (!localSettings || Object.keys(localSettings).length === 0) {
- callback();
- return;
- }
-
- chrome.storage.local.set(localSettings, function () {
- if (chrome.runtime.lastError) {
- showStatus(
- "Error: Failed to save local icon overrides - " +
- chrome.runtime.lastError.message,
- true
- );
- return;
- }
-
- callback();
- });
- });
-}
+var speederShared =
+ typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {};
+var importExportUtils = speederShared.importExport || {};
function generateBackupFilename() {
- const now = 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 getBackupManifestVersion() {
- var manifest = chrome.runtime.getManifest();
- return manifest && manifest.version ? manifest.version : "unknown";
-}
-
-function getExportableSyncSettings(syncStorage) {
- return vscBuildStoredSettingsDiff(vscExpandStoredSettings(syncStorage));
-}
-
-function getImportableSyncSettings(backup, rawSettings) {
- var importable = vscClonePlainData(rawSettings) || {};
-
- if (
- backup &&
- backup.siteRulesFormat &&
- importable.siteRulesFormat === undefined
- ) {
- importable.siteRulesFormat = backup.siteRulesFormat;
- }
-
- if (
- backup &&
- backup.siteRulesMeta &&
- importable.siteRulesMeta === undefined
- ) {
- importable.siteRulesMeta = backup.siteRulesMeta;
- }
-
- return vscExpandStoredSettings(importable);
+ return importExportUtils.generateBackupFilename(new Date());
}
function exportSettings() {
chrome.storage.sync.get(null, function (storage) {
- chrome.storage.local.get(
- EXPORTABLE_LOCAL_SETTINGS_KEYS,
- function (localStorage) {
- const localSettings = getExportableLocalSettings(localStorage);
- const syncSettings = getExportableSyncSettings(storage);
- const backup = {
- version: getBackupManifestVersion(),
- exportDate: new Date().toISOString(),
- settings: syncSettings
- };
+ chrome.storage.local.get(null, function (localStorage) {
+ const backup = importExportUtils.buildBackupPayload(
+ storage,
+ localStorage,
+ new Date()
+ );
- if (Object.keys(localSettings).length > 0) {
- backup.localSettings = localSettings;
- }
+ const dataStr = JSON.stringify(backup, null, 2);
+ const blob = new Blob([dataStr], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
- const dataStr = JSON.stringify(backup, null, 2);
- const blob = new Blob([dataStr], { type: "application/json" });
- const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = generateBackupFilename();
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
- const link = document.createElement("a");
- link.href = url;
- link.download = generateBackupFilename();
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
-
- showStatus("Settings exported successfully");
- }
- );
+ showStatus("Settings exported successfully");
+ });
});
}
@@ -139,44 +45,75 @@ function importSettings() {
const reader = new FileReader();
reader.onload = function (e) {
try {
- const backup = JSON.parse(e.target.result);
- let settingsToImport = null;
+ const parsedBackup = importExportUtils.parseImportText(e.target.result);
- // Detect backup format: check for 'settings' wrapper or raw storage keys
- if (backup.settings && typeof backup.settings === "object") {
- settingsToImport = getImportableSyncSettings(backup, backup.settings);
- } else if (typeof backup === "object" && (backup.keyBindings || backup.rememberSpeed !== undefined)) {
- settingsToImport = getImportableSyncSettings(backup, backup);
- }
-
- if (!settingsToImport) {
+ if (!parsedBackup) {
showStatus("Error: Invalid backup file format", true);
return;
}
- var localToImport = getExportableLocalSettings(backup.localSettings);
+ var settingsToImport = parsedBackup.settings;
+ var localToImport = parsedBackup.localSettings;
- function afterLocalImport() {
- persistManagedSyncSettings(settingsToImport, function (error) {
- if (error) {
+ function importLocalSettings(callback) {
+ if (parsedBackup.isWrappedBackup !== true) {
+ callback();
+ return;
+ }
+
+ chrome.storage.local.clear(function () {
+ if (chrome.runtime.lastError) {
showStatus(
- "Error: Failed to save imported settings - " + error.message,
+ "Error: Failed to clear local extension data - " +
+ chrome.runtime.lastError.message,
true
);
return;
}
- showStatus("Settings imported successfully. Reloading...");
- setTimeout(function () {
- if (typeof restore_options === "function") {
- restore_options();
- } else {
- location.reload();
- }
- }, 500);
+
+ if (localToImport && Object.keys(localToImport).length > 0) {
+ chrome.storage.local.set(localToImport, function () {
+ if (chrome.runtime.lastError) {
+ showStatus(
+ "Error: Failed to save local extension data - " +
+ chrome.runtime.lastError.message,
+ true
+ );
+ return;
+ }
+ callback();
+ });
+ return;
+ }
+
+ callback();
});
}
- replaceImportableLocalSettings(localToImport, afterLocalImport);
+ function afterLocalImport() {
+ chrome.storage.sync.clear(function () {
+ chrome.storage.sync.set(settingsToImport, function () {
+ if (chrome.runtime.lastError) {
+ showStatus(
+ "Error: Failed to save imported settings - " +
+ chrome.runtime.lastError.message,
+ true
+ );
+ return;
+ }
+ showStatus("Settings imported successfully. Reloading...");
+ setTimeout(function () {
+ if (typeof restore_options === "function") {
+ restore_options();
+ } else {
+ location.reload();
+ }
+ }, 500);
+ });
+ });
+ }
+
+ importLocalSettings(afterLocalImport);
} catch (err) {
showStatus("Error: Failed to parse backup file - " + err.message, true);
}
diff --git a/inject.js b/inject.js
index 1e31adf..386042f 100644
--- a/inject.js
+++ b/inject.js
@@ -1,6 +1,10 @@
var isUserSeek = false; // Track if seek was user-initiated
var lastToggleSpeed = {}; // Store last toggle speeds per video
-var sharedSettingsDefaults = vscGetSettingsDefaults();
+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;
@@ -13,37 +17,30 @@ function getPrimaryVideoElement() {
var tc = {
settings: {
- lastSpeed: sharedSettingsDefaults.lastSpeed,
- enabled: sharedSettingsDefaults.enabled,
+ lastSpeed: 1.0,
+ enabled: true,
speeds: {},
- displayKeyCode: sharedSettingsDefaults.displayKeyCode,
- rememberSpeed: sharedSettingsDefaults.rememberSpeed,
- forceLastSavedSpeed: sharedSettingsDefaults.forceLastSavedSpeed,
- audioBoolean: sharedSettingsDefaults.audioBoolean,
- startHidden: sharedSettingsDefaults.startHidden,
- hideWithYouTubeControls: sharedSettingsDefaults.hideWithYouTubeControls,
- hideWithControls: sharedSettingsDefaults.hideWithControls,
- hideWithControlsTimer: sharedSettingsDefaults.hideWithControlsTimer,
- controllerLocation: sharedSettingsDefaults.controllerLocation,
- controllerOpacity: sharedSettingsDefaults.controllerOpacity,
- controllerMarginTop: sharedSettingsDefaults.controllerMarginTop,
- controllerMarginRight: sharedSettingsDefaults.controllerMarginRight,
- controllerMarginBottom: sharedSettingsDefaults.controllerMarginBottom,
- controllerMarginLeft: sharedSettingsDefaults.controllerMarginLeft,
- keyBindings: Array.isArray(sharedSettingsDefaults.keyBindings)
- ? sharedSettingsDefaults.keyBindings.slice()
- : [],
- siteRules: Array.isArray(sharedSettingsDefaults.siteRules)
- ? sharedSettingsDefaults.siteRules.slice()
- : [],
- controllerButtons: Array.isArray(sharedSettingsDefaults.controllerButtons)
- ? sharedSettingsDefaults.controllerButtons.slice()
- : ["rewind", "slower", "faster", "advance", "display"],
+ rememberSpeed: false,
+ forceLastSavedSpeed: false,
+ audioBoolean: false,
+ startHidden: false,
+ hideWithYouTubeControls: false,
+ hideWithControls: false,
+ hideWithControlsTimer: 2.0,
+ controllerLocation: "top-left",
+ controllerOpacity: 0.3,
+ controllerMarginTop: 0,
+ controllerMarginRight: 0,
+ controllerMarginBottom: 65,
+ controllerMarginLeft: 0,
+ keyBindings: [],
+ siteRules: [],
+ controllerButtons: ["rewind", "slower", "faster", "advance", "display"],
defaultLogLevel: 3,
logLevel: 3,
- enableSubtitleNudge: sharedSettingsDefaults.enableSubtitleNudge,
- subtitleNudgeInterval: sharedSettingsDefaults.subtitleNudgeInterval,
- subtitleNudgeAmount: sharedSettingsDefaults.subtitleNudgeAmount,
+ enableSubtitleNudge: false,
+ subtitleNudgeInterval: 50, // Default 50ms balances subtitle tracking with CPU cost
+ subtitleNudgeAmount: 0.001,
customButtonIcons: {}
},
mediaElements: [],
@@ -64,20 +61,23 @@ var vscObservedRoots = new WeakSet();
var requestIdle =
typeof window.requestIdleCallback === "function"
? window.requestIdleCallback.bind(window)
- : function (callback, options) {
+ : function(callback, options) {
return setTimeout(callback, (options && options.timeout) || 1);
};
-var controllerLocations = [
- "top-left",
- "top-center",
- "top-right",
- "middle-right",
- "bottom-right",
- "bottom-center",
- "bottom-left",
- "middle-left"
-];
-var defaultControllerLocation = controllerLocations[0];
+var controllerLocations = Array.isArray(controllerUtils.controllerLocations)
+ ? controllerUtils.controllerLocations.slice()
+ : [
+ "top-left",
+ "top-center",
+ "top-right",
+ "middle-right",
+ "bottom-right",
+ "bottom-center",
+ "bottom-left",
+ "middle-left"
+ ];
+var defaultControllerLocation =
+ controllerUtils.defaultControllerLocation || controllerLocations[0];
var controllerLocationStyles = {
"top-left": {
top: "10px",
@@ -123,76 +123,27 @@ var controllerLocationStyles = {
/* `label` fallback only when ui-icons has no path for the action. */
var controllerButtonDefs = {
- rewind: { label: "", className: "rw" },
- slower: { label: "", className: "" },
- faster: { label: "", className: "" },
- advance: { label: "", className: "rw" },
- display: { label: "", className: "hideButton" },
- reset: { label: "\u21BB", className: "" },
- fast: { label: "", className: "" },
- settings: { label: "", className: "" },
- pause: { label: "", className: "" },
- muted: { label: "", className: "" },
- mark: { label: "", className: "" },
- jump: { label: "", className: "" }
+ rewind: { label: "", className: "rw" },
+ slower: { label: "", className: "" },
+ faster: { label: "", className: "" },
+ advance: { label: "", className: "rw" },
+ display: { label: "", className: "hideButton" },
+ reset: { label: "\u21BB", className: "" },
+ fast: { label: "", className: "" },
+ nudge: { label: "", className: "" },
+ pause: { label: "", className: "" },
+ muted: { label: "", className: "" },
+ louder: { label: "", className: "" },
+ softer: { label: "", className: "" },
+ mark: { label: "", className: "" },
+ jump: { label: "", className: "" },
+ settings: { label: "", className: "" }
};
-var keyCodeToEventKey = {
- 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) {
+function createDefaultBinding(action, code, value) {
return {
action: action,
- key: key,
- keyCode: keyCode,
- code: null,
- disabled: false,
+ code: code,
value: value,
force: false,
predefined: true
@@ -203,89 +154,73 @@ function defaultKeyBindings(storage) {
return [
createDefaultBinding(
"slower",
- "S",
- Number(storage.slowerKeyCode) || 83,
+ "KeyS",
Number(storage.speedStep) || 0.1
),
createDefaultBinding(
"faster",
- "D",
- Number(storage.fasterKeyCode) || 68,
+ "KeyD",
Number(storage.speedStep) || 0.1
),
createDefaultBinding(
"rewind",
- "Z",
- Number(storage.rewindKeyCode) || 90,
+ "KeyZ",
Number(storage.rewindTime) || 10
),
createDefaultBinding(
"advance",
- "X",
- Number(storage.advanceKeyCode) || 88,
+ "KeyX",
Number(storage.advanceTime) || 10
),
createDefaultBinding(
"reset",
- "R",
- Number(storage.resetKeyCode) || 82,
- 0
+ "KeyR",
+ 1.0
),
createDefaultBinding(
"fast",
- "G",
- Number(storage.fastKeyCode) || 71,
+ "KeyG",
Number(storage.fastSpeed) || 1.8
),
createDefaultBinding(
"move",
- "P",
- 80,
+ "KeyP",
0
),
createDefaultBinding(
"toggleSubtitleNudge",
- "N",
- 78,
+ "KeyN",
0
)
];
}
-function ensureDefaultKeyBinding(action, key, keyCode, value) {
+function ensureDefaultKeyBinding(action, code, value) {
if (tc.settings.keyBindings.some((binding) => binding.action === action)) {
return false;
}
tc.settings.keyBindings.push(
- createDefaultBinding(action, key, keyCode, value)
+ createDefaultBinding(action, code, value)
);
return true;
}
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;
+ return keyBindingUtils.getLegacyKeyCode(binding);
}
function normalizeControllerLocation(location) {
- if (controllerLocations.includes(location)) return location;
- return defaultControllerLocation;
+ return controllerUtils.normalizeControllerLocation(
+ location,
+ defaultControllerLocation
+ );
}
var CONTROLLER_MARGIN_MAX_PX = 200;
function normalizeControllerMarginPx(value, fallback) {
- var n = Number(value);
- if (!Number.isFinite(n)) return fallback;
- return Math.min(
- CONTROLLER_MARGIN_MAX_PX,
- Math.max(0, Math.round(n))
- );
+ return controllerUtils.clampControllerMarginPx(value, fallback);
}
function applyControllerMargins(controller) {
@@ -324,9 +259,7 @@ function applyControllerMargins(controller) {
}
function getNextControllerLocation(location) {
- var normalizedLocation = normalizeControllerLocation(location);
- var currentIndex = controllerLocations.indexOf(normalizedLocation);
- return controllerLocations[(currentIndex + 1) % controllerLocations.length];
+ return controllerUtils.getNextControllerLocation(location);
}
function getControllerElement(videoOrController) {
@@ -438,165 +371,6 @@ function applyControllerLocation(videoController, location) {
);
}
-function getYouTubeAutoHidePlayer(video) {
- if (!video || !isOnYouTube()) return null;
-
- return video.closest(".html5-video-player") || video.closest("#movie_player");
-}
-
-function getAutoHideModeForVideo(video) {
- if (!tc.settings.hideWithControls) return "off";
- return getYouTubeAutoHidePlayer(video) ? "youtube" : "generic";
-}
-
-function getControllerMountParent(video, parentHint) {
- var parentEl = parentHint || (video && (video.parentElement || video.parentNode));
- if (!parentEl) return null;
-
- switch (true) {
- case location.hostname == "www.amazon.com":
- case location.hostname == "www.reddit.com":
- case /hbogo\./.test(location.hostname):
- return parentEl.parentElement || parentEl;
- case location.hostname == "www.facebook.com":
- var facebookParent = parentEl;
- for (
- var depth = 0;
- depth < 8 && facebookParent && facebookParent.parentElement;
- depth++
- ) {
- facebookParent = facebookParent.parentElement;
- }
- return facebookParent || parentEl;
- case location.hostname == "tv.apple.com":
- var root = parentEl.getRootNode();
- var scrim = root && root.querySelector ? root.querySelector(".scrim") : null;
- return scrim || parentEl;
- case location.hostname == "www.youtube.com":
- case location.hostname == "m.youtube.com":
- case location.hostname == "music.youtube.com":
- return getYouTubeAutoHidePlayer(video) || parentEl;
- default:
- return parentEl;
- }
-}
-
-function getControllerBehaviorSignature(video) {
- return JSON.stringify({
- startHidden: Boolean(tc.settings.startHidden),
- hideWithControls: Boolean(tc.settings.hideWithControls),
- hideWithControlsTimer: Number(tc.settings.hideWithControlsTimer),
- controllerLocation: normalizeControllerLocation(tc.settings.controllerLocation),
- controllerOpacity: Number(tc.settings.controllerOpacity),
- controllerMarginTop: normalizeControllerMarginPx(tc.settings.controllerMarginTop, 0),
- controllerMarginBottom: normalizeControllerMarginPx(
- tc.settings.controllerMarginBottom,
- 0
- ),
- controllerButtons: Array.isArray(tc.settings.controllerButtons)
- ? tc.settings.controllerButtons.slice()
- : [],
- enableSubtitleNudge: Boolean(tc.settings.enableSubtitleNudge),
- subtitleNudgeInterval: Number(tc.settings.subtitleNudgeInterval),
- autoHideMode: getAutoHideModeForVideo(video),
- mediaTag: video && video.tagName ? video.tagName : ""
- });
-}
-
-function rebuildControllerForVideo(video, parentHint, reason) {
- if (!video) return null;
-
- var previous = video.vsc || null;
- var preservedState = previous
- ? {
- mark: previous.mark,
- resetToggleArmed: previous.resetToggleArmed === true,
- subtitleNudgeEnabledOverride: previous.subtitleNudgeEnabledOverride,
- userHidden:
- Boolean(previous.div) &&
- previous.div.classList.contains("vsc-hidden")
- }
- : null;
-
- if (previous) {
- previous.remove();
- }
-
- if (!video.isConnected || !hasUsableMediaSource(video)) {
- return null;
- }
-
- var nextController = new tc.videoController(
- video,
- parentHint || video.parentElement || video.parentNode
- );
- if (!nextController) return null;
-
- if (preservedState) {
- nextController.mark = preservedState.mark;
- nextController.resetToggleArmed = preservedState.resetToggleArmed;
-
- if (
- typeof preservedState.subtitleNudgeEnabledOverride === "boolean"
- ) {
- nextController.subtitleNudgeEnabledOverride =
- preservedState.subtitleNudgeEnabledOverride;
- updateSubtitleNudgeIndicator(video);
- if (!preservedState.subtitleNudgeEnabledOverride) {
- nextController.stopSubtitleNudge();
- } else if (!video.paused && video.playbackRate !== 1.0) {
- nextController.startSubtitleNudge();
- }
- }
-
- if (preservedState.userHidden && nextController.div) {
- nextController.div.classList.add("vsc-hidden");
- }
- }
-
- log("Rebuilt controller: " + (reason || "refresh"), 4);
- return nextController;
-}
-
-function refreshManagedController(video, parentHint) {
- if (!video || !video.vsc) return null;
- if (!video.isConnected) {
- removeController(video);
- return null;
- }
-
- var controller = video.vsc;
- controller.parent = video.parentElement || parentHint || controller.parent;
-
- var expectedMountParent = getControllerMountParent(video, controller.parent);
- var nextSignature = getControllerBehaviorSignature(video);
- var wrapper = controller.div;
- var needsRebuild =
- !wrapper ||
- !wrapper.isConnected ||
- !expectedMountParent ||
- wrapper.parentNode !== expectedMountParent ||
- controller.behaviorSignature !== nextSignature;
-
- if (needsRebuild) {
- return rebuildControllerForVideo(
- video,
- controller.parent,
- "DOM/source/site-rule change"
- );
- }
-
- controller.mountParent = expectedMountParent;
- controller.behaviorSignature = nextSignature;
- applyControllerLocation(controller, tc.settings.controllerLocation);
- var controllerEl = getControllerElement(controller);
- if (controllerEl) {
- controllerEl.style.opacity = String(tc.settings.controllerOpacity);
- }
- updateSubtitleNudgeIndicator(video);
- return controller;
-}
-
function captureSiteRuleBase() {
tc.siteRuleBase = {
startHidden: tc.settings.startHidden,
@@ -615,7 +389,7 @@ function captureSiteRuleBase() {
? tc.settings.controllerButtons.slice()
: tc.settings.controllerButtons,
keyBindings: Array.isArray(tc.settings.keyBindings)
- ? tc.settings.keyBindings.map(function (binding) {
+ ? tc.settings.keyBindings.map(function(binding) {
return Object.assign({}, binding);
})
: tc.settings.keyBindings
@@ -641,7 +415,7 @@ function resetSettingsFromSiteRuleBase() {
? base.controllerButtons.slice()
: base.controllerButtons;
tc.settings.keyBindings = Array.isArray(base.keyBindings)
- ? base.keyBindings.map(function (binding) {
+ ? base.keyBindings.map(function(binding) {
return Object.assign({}, binding);
})
: base.keyBindings;
@@ -705,44 +479,41 @@ function cycleControllerLocation(video) {
}
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;
+ return keyBindingUtils.normalizeBindingKey(key);
}
-function legacyKeyCodeToBinding(keyCode) {
- if (!Number.isInteger(keyCode)) return null;
- 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 legacyBindingKeyToCode(key) {
+ return keyBindingUtils.legacyBindingKeyToCode(key);
}
-function normalizeStoredBinding(binding, fallbackKeyCode) {
- var fallbackBinding = legacyKeyCodeToBinding(fallbackKeyCode);
- if (!binding) return fallbackBinding;
+function legacyKeyCodeToCode(keyCode) {
+ return keyBindingUtils.legacyKeyCodeToCode(keyCode);
+}
+
+function inferBindingCode(binding, fallbackCode) {
+ return keyBindingUtils.inferBindingCode(binding, fallbackCode);
+}
+
+function normalizeStoredBinding(binding, fallbackCode) {
+ if (!binding) {
+ if (!fallbackCode) return null;
+ return {
+ code: fallbackCode,
+ disabled: false,
+ value: 0,
+ force: "false",
+ predefined: false
+ };
+ }
if (
binding.disabled === true ||
- (binding.key === null &&
- binding.keyCode === null &&
- binding.code === null)
+ (binding.code === null &&
+ binding.key === null &&
+ binding.keyCode === null)
) {
return {
action: binding.action,
- key: null,
- keyCode: null,
code: null,
disabled: true,
value: Number(binding.value),
@@ -751,46 +522,20 @@ function normalizeStoredBinding(binding, fallbackKeyCode) {
};
}
+ var normalizedCode = inferBindingCode(binding, fallbackCode);
+ if (!normalizedCode) {
+ return null;
+ }
+
var normalized = {
action: binding.action,
- key: null,
- keyCode: null,
- code:
- typeof binding.code === "string" && binding.code.length > 0
- ? binding.code
- : null,
+ code: normalizedCode,
disabled: false,
value: Number(binding.value),
force: String(binding.force) === "true" ? "true" : "false",
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;
}
@@ -907,19 +652,32 @@ function isSubtitleNudgeSupported(video) {
return Boolean(video);
}
+function isSubtitleNudgeAvailableForVideo(video) {
+ return isSubtitleNudgeSupported(video) && Boolean(tc.settings.enableSubtitleNudge);
+}
+
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") {
return video.vsc.subtitleNudgeEnabledOverride;
}
- return tc.settings.enableSubtitleNudge;
+ return true;
}
function setSubtitleNudgeEnabledForVideo(video, enabled) {
if (!video || !video.vsc) return false;
+ if (!isSubtitleNudgeAvailableForVideo(video)) {
+ video.vsc.subtitleNudgeEnabledOverride = null;
+ video.vsc.stopSubtitleNudge();
+ updateSubtitleNudgeIndicator(video);
+ return false;
+ }
+
var normalizedEnabled = Boolean(enabled);
video.vsc.subtitleNudgeEnabledOverride = normalizedEnabled;
@@ -936,7 +694,7 @@ function setSubtitleNudgeEnabledForVideo(video, enabled) {
if (flashEl) {
flashEl.classList.add("visible");
clearTimeout(flashEl._flashTimer);
- flashEl._flashTimer = setTimeout(function () {
+ flashEl._flashTimer = setTimeout(function() {
flashEl.classList.remove("visible");
}, 1500);
}
@@ -983,14 +741,19 @@ function renderSubtitleNudgeIndicatorContent(target, isEnabled) {
function updateSubtitleNudgeIndicator(video) {
if (!video || !video.vsc) return;
+ var isAvailable = isSubtitleNudgeAvailableForVideo(video);
var isEnabled = isSubtitleNudgeEnabledForVideo(video);
- var title = isEnabled ? "Subtitle nudge enabled" : "Subtitle nudge disabled";
+ var title = !isAvailable
+ ? "Subtitle nudge unavailable on this site"
+ : isEnabled
+ ? "Subtitle nudge enabled"
+ : "Subtitle nudge disabled";
var indicator = video.vsc.subtitleNudgeIndicator;
if (indicator) {
renderSubtitleNudgeIndicatorContent(indicator, isEnabled);
indicator.dataset.enabled = isEnabled ? "true" : "false";
- indicator.dataset.supported = "true";
+ indicator.dataset.supported = isAvailable ? "true" : "false";
indicator.title = title;
indicator.setAttribute("aria-label", title);
}
@@ -999,7 +762,7 @@ function updateSubtitleNudgeIndicator(video) {
if (flashEl) {
renderSubtitleNudgeIndicatorContent(flashEl, isEnabled);
flashEl.dataset.enabled = isEnabled ? "true" : "false";
- flashEl.dataset.supported = "true";
+ flashEl.dataset.supported = isAvailable ? "true" : "false";
flashEl.setAttribute("aria-label", title);
}
}
@@ -1010,7 +773,7 @@ function schedulePersistLastSpeed(speed) {
tc.pendingLastSpeedValue = speed;
if (tc.pendingLastSpeedSave !== null) return;
- tc.pendingLastSpeedSave = setTimeout(function () {
+ tc.pendingLastSpeedSave = setTimeout(function() {
var speedToPersist = tc.pendingLastSpeedValue;
tc.pendingLastSpeedSave = null;
@@ -1018,7 +781,8 @@ function schedulePersistLastSpeed(speed) {
return;
}
- chrome.storage.sync.set({ lastSpeed: speedToPersist }, function () { });
+ chrome.storage.sync.set({ lastSpeed: speedToPersist }, function() {
+ });
tc.persistedLastSpeed = speedToPersist;
}, 250);
}
@@ -1078,8 +842,8 @@ function applySourceTransitionPolicy(video, forceUpdate) {
setSpeed(video, desiredSpeed, false, false);
}
- // Same-tab SPA or DOM-driven player swaps can change the effective rule output
- // after the media source updates, so refresh or rebuild controllers here too.
+ // 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();
}
@@ -1100,7 +864,7 @@ function scheduleSpeedRestore(video, desiredSpeed, reason) {
clearTimeout(video.vsc.restoreSpeedTimer);
}
- video.vsc.restoreSpeedTimer = setTimeout(function () {
+ video.vsc.restoreSpeedTimer = setTimeout(function() {
if (!video.vsc) return;
if (Math.abs(video.playbackRate - desiredSpeed) > 0.01) {
@@ -1147,19 +911,13 @@ function takePendingRateChange(video, currentSpeed) {
}
function matchesKeyBinding(binding, event) {
- if (!binding || binding.disabled) return false;
-
- var normalizedEventKey = normalizeBindingKey(event.key);
- if (binding.key && normalizedEventKey) {
- return binding.key === normalizedEventKey;
- }
-
- if (binding.code && event.code) {
- return binding.code === event.code;
- }
-
- var legacyKeyCode = getLegacyKeyCode(binding);
- return Number.isInteger(legacyKeyCode) && legacyKeyCode === event.keyCode;
+ return Boolean(
+ binding &&
+ binding.disabled !== true &&
+ typeof binding.code === "string" &&
+ binding.code.length > 0 &&
+ binding.code === event.code
+ );
}
function mediaSelector() {
@@ -1189,7 +947,7 @@ function hasUsableMediaSource(node) {
}
if (node.querySelectorAll) {
- return Array.from(node.querySelectorAll("source[src]")).some(function (
+ return Array.from(node.querySelectorAll("source[src]")).some(function(
source
) {
var src = source.getAttribute("src");
@@ -1201,14 +959,8 @@ function hasUsableMediaSource(node) {
}
function ensureController(node, parent) {
- if (!isMediaElement(node)) return node && node.vsc;
-
- if (!node.isConnected) {
- removeController(node);
- return null;
- }
-
- if (!node.vsc && !hasUsableMediaSource(node)) {
+ if (!isMediaElement(node) || node.vsc) return node && node.vsc;
+ if (!hasUsableMediaSource(node)) {
log(
`Deferring controller creation for ${node.tagName}: no usable source yet`,
5
@@ -1219,13 +971,9 @@ function ensureController(node, parent) {
// 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) {
- removeController(node);
return null;
}
-
- if (node.vsc) {
- return refreshManagedController(node, parent);
- }
+ refreshAllControllerGeometry();
log(
`Creating controller for ${node.tagName}: ${node.src || node.currentSrc || "no src"}`,
@@ -1338,7 +1086,7 @@ function patchAttachShadow() {
}
var originalAttachShadow = Element.prototype.attachShadow;
- Element.prototype.attachShadow = function () {
+ Element.prototype.attachShadow = function() {
var shadowRoot = originalAttachShadow.apply(this, arguments);
try {
if (shadowRoot) {
@@ -1369,8 +1117,7 @@ function log(message, level) {
}
}
-chrome.storage.sync.get(null, function (storage) {
- storage = vscExpandStoredSettings(storage);
+chrome.storage.sync.get(tc.settings, function(storage) {
var storedBindings = Array.isArray(storage.keyBindings)
? storage.keyBindings
: [];
@@ -1381,6 +1128,18 @@ chrome.storage.sync.get(null, function (storage) {
if (tc.settings.keyBindings.length === 0) {
tc.settings.keyBindings = defaultKeyBindings(storage);
+ tc.settings.version = "0.5.3";
+ chrome.storage.sync.set({
+ keyBindings: tc.settings.keyBindings,
+ version: tc.settings.version,
+ rememberSpeed: tc.settings.rememberSpeed,
+ forceLastSavedSpeed: tc.settings.forceLastSavedSpeed,
+ audioBoolean: tc.settings.audioBoolean,
+ startHidden: tc.settings.startHidden,
+ enabled: tc.settings.enabled,
+ controllerLocation: tc.settings.controllerLocation,
+ controllerOpacity: tc.settings.controllerOpacity
+ });
}
tc.settings.lastSpeed = Number(storage.lastSpeed);
if (!isValidSpeed(tc.settings.lastSpeed) && tc.settings.lastSpeed !== 1.0) {
@@ -1392,7 +1151,6 @@ chrome.storage.sync.get(null, function (storage) {
tc.settings.lastSpeed = 1.0;
}
tc.persistedLastSpeed = tc.settings.lastSpeed;
- tc.settings.displayKeyCode = Number(storage.displayKeyCode);
tc.settings.rememberSpeed = Boolean(storage.rememberSpeed);
tc.settings.forceLastSavedSpeed = Boolean(storage.forceLastSavedSpeed);
tc.settings.audioBoolean = Boolean(storage.audioBoolean);
@@ -1447,25 +1205,17 @@ chrome.storage.sync.get(null, function (storage) {
addedDefaultBinding =
ensureDefaultKeyBinding(
"display",
- "V",
- Number(storage.displayKeyCode) || 86,
+ "KeyV",
0
) || addedDefaultBinding;
addedDefaultBinding =
- ensureDefaultKeyBinding("move", "P", 80, 0) || addedDefaultBinding;
+ ensureDefaultKeyBinding("move", "KeyP", 0) || addedDefaultBinding;
addedDefaultBinding =
- ensureDefaultKeyBinding("toggleSubtitleNudge", "N", 78, 0) ||
+ ensureDefaultKeyBinding("toggleSubtitleNudge", "KeyN", 0) ||
addedDefaultBinding;
if (addedDefaultBinding) {
- var keyBindingsDiff = vscBuildStoredSettingsDiff({
- keyBindings: tc.settings.keyBindings
- });
- if (Object.prototype.hasOwnProperty.call(keyBindingsDiff, "keyBindings")) {
- chrome.storage.sync.set({ keyBindings: keyBindingsDiff.keyBindings });
- } else {
- chrome.storage.sync.remove("keyBindings");
- }
+ chrome.storage.sync.set({ keyBindings: tc.settings.keyBindings });
}
captureSiteRuleBase();
patchAttachShadow();
@@ -1473,7 +1223,7 @@ chrome.storage.sync.get(null, function (storage) {
// We use a global flag to ensure the listener is only attached once.
if (!window.vscMessageListener) {
chrome.runtime.onMessage.addListener(
- function (request, sender, sendResponse) {
+ function(request, sender, sendResponse) {
if (request.action === "rescan_page") {
log("Re-scan command received from popup.", 4);
initializeWhenReady(document, true);
@@ -1514,7 +1264,7 @@ chrome.storage.sync.get(null, function (storage) {
// Set the flag to prevent adding the listener again.
window.vscMessageListener = true;
}
- chrome.storage.local.get(["customButtonIcons"], function (loc) {
+ chrome.storage.local.get(["customButtonIcons"], function(loc) {
tc.settings.customButtonIcons =
loc &&
loc.customButtonIcons &&
@@ -1524,18 +1274,18 @@ chrome.storage.sync.get(null, function (storage) {
if (!window.vscCustomIconListener) {
window.vscCustomIconListener = true;
- chrome.storage.onChanged.addListener(function (changes, area) {
+ 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) {
+ 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) {
+ shadow.querySelectorAll("button[data-action]").forEach(function(btn) {
var act = btn.dataset.action;
if (!act) return;
var svg =
@@ -1580,6 +1330,7 @@ function getKeyBindings(action, what = "value") {
return false;
}
}
+
function setKeyBindings(action, value) {
tc.settings.keyBindings.find((item) => item.action === action)["value"] =
value;
@@ -1616,7 +1367,7 @@ function createControllerButton(doc, action, label, className) {
}
function defineVideoController() {
- tc.videoController = function (target, parent) {
+ tc.videoController = function(target, parent) {
if (target.vsc) return target.vsc;
tc.mediaElements.push(target);
target.vsc = this;
@@ -1636,7 +1387,7 @@ function defineVideoController() {
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);
this.targetSpeed = storedSpeed;
@@ -1656,16 +1407,9 @@ function defineVideoController() {
return;
}
- this.mountParent =
- this.div.parentNode || getControllerMountParent(target, this.parent);
- this.behaviorSignature = getControllerBehaviorSignature(target);
+ 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 (
event.type === "loadedmetadata" ||
event.type === "loadeddata" ||
@@ -1783,7 +1527,7 @@ function defineVideoController() {
this.startSubtitleNudge();
};
- tc.videoController.prototype.remove = function () {
+ tc.videoController.prototype.remove = function() {
this.stopSubtitleNudge();
if (this.youTubeAutoHideObserver) {
this.youTubeAutoHideObserver.disconnect();
@@ -1815,7 +1559,7 @@ function defineVideoController() {
if (idx != -1) tc.mediaElements.splice(idx, 1);
};
- tc.videoController.prototype.startSubtitleNudge = function () {
+ tc.videoController.prototype.startSubtitleNudge = function() {
if (
!isSubtitleNudgeSupported(this.video) ||
!isSubtitleNudgeEnabledForVideo(this.video) ||
@@ -1888,7 +1632,7 @@ function defineVideoController() {
log(`Nudge: Starting with interval ${tc.settings.subtitleNudgeInterval}ms.`, 5);
};
- tc.videoController.prototype.stopSubtitleNudge = function () {
+ tc.videoController.prototype.stopSubtitleNudge = function() {
if (this.nudgeAnimationId !== null) {
clearTimeout(this.nudgeAnimationId);
this.nudgeAnimationId = null;
@@ -1906,7 +1650,7 @@ function defineVideoController() {
// doesn't lose the user's intended speed if the site hijacks it.
};
- tc.videoController.prototype.performImmediateNudge = function () {
+ tc.videoController.prototype.performImmediateNudge = function() {
if (
!isSubtitleNudgeSupported(this.video) ||
!isSubtitleNudgeEnabledForVideo(this.video) ||
@@ -1936,11 +1680,11 @@ function defineVideoController() {
log(`Immediate nudge performed at rate ${targetRate.toFixed(2)}`, 5);
};
- tc.videoController.prototype.setupYouTubeAutoHide = function (wrapper) {
- if (!wrapper) return;
+ tc.videoController.prototype.setupYouTubeAutoHide = function(wrapper) {
+ if (!wrapper || !isOnYouTube()) return;
const video = this.video;
- const ytPlayer = getYouTubeAutoHidePlayer(video);
+ const ytPlayer = video.closest(".html5-video-player");
if (!ytPlayer) {
log("YouTube player not found for auto-hide setup", 4);
return;
@@ -1952,7 +1696,7 @@ function defineVideoController() {
// The vsc-hidden class (from V key) takes precedence via CSS specificity
if (ytPlayer.classList.contains("ytp-autohide")) {
wrapper.classList.add("ytp-autohide");
-
+
// Immediately end any temporary "vsc-show" state to hide with YouTube
// UNLESS it was forced by a shortcut (vsc-forced-show)
if (!wrapper.classList.contains("vsc-forced-show")) {
@@ -1962,7 +1706,7 @@ function defineVideoController() {
wrapper.showTimeOut = undefined;
}
}
-
+
log("YouTube controls hidden, hiding controller", 5);
} else {
wrapper.classList.remove("ytp-autohide");
@@ -2012,7 +1756,7 @@ function defineVideoController() {
};
};
- tc.videoController.prototype.setupGenericAutoHide = function (wrapper) {
+ tc.videoController.prototype.setupGenericAutoHide = function(wrapper) {
if (!wrapper) return;
const video = this.video;
@@ -2071,7 +1815,7 @@ function defineVideoController() {
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 speed = this.video.playbackRate.toFixed(2);
var wrapper = doc.createElement("div");
@@ -2080,8 +1824,7 @@ function defineVideoController() {
wrapper.classList.add("vsc-nosource");
if (tc.settings.startHidden) wrapper.classList.add("vsc-hidden");
// Use lower z-index for non-YouTube sites to avoid overlapping modals
- if (!getYouTubeAutoHidePlayer(this.video))
- wrapper.classList.add("vsc-non-youtube");
+ if (!isOnYouTube()) wrapper.classList.add("vsc-non-youtube");
var shadow = wrapper.attachShadow({ mode: "open" });
var shadowStylesheet = doc.createElement("link");
shadowStylesheet.rel = "stylesheet";
@@ -2110,7 +1853,7 @@ function defineVideoController() {
var subtitleNudgeIndicator = null;
- buttonConfig.forEach(function (btnId) {
+ buttonConfig.forEach(function(btnId) {
if (btnId === "nudge") {
subtitleNudgeIndicator = doc.createElement("span");
subtitleNudgeIndicator.id = "nudge-indicator";
@@ -2142,20 +1885,22 @@ function defineVideoController() {
this.subtitleNudgeIndicator = subtitleNudgeIndicator;
this.nudgeFlashIndicator = nudgeFlashIndicator;
this.resetButtonEl =
- shadow.querySelector('button[data-action="reset"]') || null;
+ shadow.querySelector("button[data-action=\"reset\"]") || null;
this.resetToggleArmed = false;
if (subtitleNudgeIndicator) {
updateSubtitleNudgeIndicator(this.video);
}
+
function blurAfterPointerTap(target, e) {
if (!target || typeof target.blur !== "function") return;
var pt = e.pointerType;
if (pt === "mouse" || pt === "touch" || (!pt && e.detail > 0)) {
- requestAnimationFrame(function () {
+ requestAnimationFrame(function() {
target.blur();
});
}
}
+
dragHandle.addEventListener(
"mousedown",
(e) => {
@@ -2197,13 +1942,13 @@ function defineVideoController() {
// Setup auto-hide observers if enabled
if (tc.settings.hideWithControls) {
- if (getAutoHideModeForVideo(this.video) === "youtube") {
+ if (isOnYouTube()) {
this.setupYouTubeAutoHide(wrapper);
} else {
this.setupGenericAutoHide(wrapper);
}
}
-
+
var fragment = doc.createDocumentFragment();
fragment.appendChild(wrapper);
const parentEl = this.parent || this.video.parentElement;
@@ -2271,22 +2016,21 @@ function defineVideoController() {
function applySiteRuleOverrides() {
resetSettingsFromSiteRuleBase();
- tc.activeSiteRule = null;
if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) {
return false;
}
var currentUrl = location.href;
- var matchedRule = vscMatchSiteRule(currentUrl, tc.settings.siteRules);
+ var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules);
if (!matchedRule) return false;
tc.activeSiteRule = matchedRule;
- log("Matched site rule overrides for current URL", 4);
+ log(`Matched site rule: ${matchedRule.pattern}`, 4);
// Check if extension should be enabled/disabled on this site
- if (vscIsSiteRuleDisabled(matchedRule)) {
+ if (siteRuleUtils.isSiteRuleDisabled(matchedRule)) {
log(`Extension disabled for site: ${currentUrl}`, 4);
return true;
}
@@ -2317,7 +2061,7 @@ function applySiteRuleOverrides() {
[
"controllerMarginTop",
"controllerMarginBottom"
- ].forEach(function (key) {
+ ].forEach(function(key) {
tc.settings[key] = normalizeControllerMarginPx(tc.settings[key], 0);
});
@@ -2346,22 +2090,23 @@ function applySiteRuleOverrides() {
return false;
}
-/** Re-match site rules for current URL and refresh or rebuild every controller. */
+/** 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();
- var videos = tc.mediaElements.slice();
-
- if (!tc.settings.enabled || siteDisabled) {
- videos.forEach(function (video) {
- removeController(video);
- });
- return;
- }
-
- videos.forEach(function (video) {
- if (!video) return;
- ensureController(video, video.parentElement || video.parentNode);
- });
+ if (!tc.settings.enabled || siteDisabled) return;
+ refreshAllControllerGeometry();
}
function shouldPreserveDesiredSpeed(video, speed) {
@@ -2381,6 +2126,7 @@ function shouldPreserveDesiredSpeed(video, speed) {
function setupListener(root) {
root = root || document;
if (root.vscRateListenerAttached) return;
+
function updateSpeedFromEvent(video, skipResetDisarm) {
if (!video.vsc || !video.vsc.speedIndicator) return;
if (!skipResetDisarm) {
@@ -2401,9 +2147,10 @@ function setupListener(root) {
else video.vsc.startSubtitleNudge();
}
}
+
root.addEventListener(
"ratechange",
- function (event) {
+ function(event) {
if (tc.isNudging) return;
var video = event.target;
if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
@@ -2447,6 +2194,7 @@ function setupListener(root) {
}
var vscInitializedDocuments = new Set();
+
function clearPendingInitialization(doc) {
if (!doc || !doc.vscPendingInitializeHandler) return;
@@ -2483,7 +2231,7 @@ function initializeWhenReady(doc, forceReinit = false) {
if (doc.vscPendingInitializeHandler) return;
- var pendingInitializeHandler = function () {
+ var pendingInitializeHandler = function() {
tryInitializeDocument(doc, doc.vscPendingForceReinit === true);
};
@@ -2498,6 +2246,7 @@ function initializeWhenReady(doc, forceReinit = false) {
setTimeout(pendingInitializeHandler, 0);
}
}
+
function inIframe() {
try {
return window.self !== window.top;
@@ -2510,13 +2259,14 @@ function attachKeydownListeners(doc) {
var docs = [doc];
try {
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;
keyDoc.addEventListener(
"keydown",
- function (event) {
+ function(event) {
if (
!event.getModifierState ||
event.getModifierState("Alt") ||
@@ -2539,7 +2289,7 @@ function attachKeydownListeners(doc) {
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);
});
@@ -2563,24 +2313,24 @@ function attachMutationObserver(root) {
var pendingMutations = [];
var mutationProcessingScheduled = false;
- var observer = new MutationObserver(function (mutations) {
+ var observer = new MutationObserver(function(mutations) {
pendingMutations.push(...mutations);
if (mutationProcessingScheduled) return;
mutationProcessingScheduled = true;
requestIdle(
- function () {
+ function() {
var mutationsToProcess = pendingMutations.splice(0);
mutationProcessingScheduled = false;
- mutationsToProcess.forEach(function (mutation) {
+ mutationsToProcess.forEach(function(mutation) {
if (mutation.type === "childList") {
- mutation.addedNodes.forEach(function (node) {
+ mutation.addedNodes.forEach(function(node) {
// Skip text nodes, comments, etc. — only elements can contain media
if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, true);
});
- mutation.removedNodes.forEach(function (node) {
+ mutation.removedNodes.forEach(function(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
scanNodeForMedia(node, node.parentNode || mutation.target, false);
});
@@ -2629,7 +2379,7 @@ function attachMutationObserver(root) {
function attachMediaDetectionListeners(root) {
if (root.vscMediaEventListenersAttached) return;
- var handleDetectedMedia = function (event) {
+ var handleDetectedMedia = function(event) {
var target = event.target;
if (!isMediaElement(target)) return;
ensureController(target, target.parentElement || target.parentNode);
@@ -2642,21 +2392,22 @@ function attachMediaDetectionListeners(root) {
"canplay",
"playing",
"play"
- ].forEach(function (eventName) {
+ ].forEach(function(eventName) {
root.addEventListener(eventName, handleDetectedMedia, true);
});
root.vscMediaEventListenersAttached = true;
}
function attachIframeListeners(doc) {
- Array.from(doc.getElementsByTagName("iframe")).forEach(function (frame) {
+ Array.from(doc.getElementsByTagName("iframe")).forEach(function(frame) {
if (!frame.vscLoadListenerAttached) {
- frame.addEventListener("load", function () {
+ frame.addEventListener("load", function() {
try {
if (frame.contentDocument) {
initializeWhenReady(frame.contentDocument, true);
}
- } catch (e) { }
+ } catch (e) {
+ }
});
frame.vscLoadListenerAttached = true;
}
@@ -2665,24 +2416,25 @@ function attachIframeListeners(doc) {
if (frame.contentDocument) {
initializeWhenReady(frame.contentDocument);
}
- } catch (e) { }
+ } catch (e) {
+ }
});
}
function attachNavigationListeners() {
if (window.vscNavigationListenersAttached) return;
- var scheduleRescan = function () {
+ var scheduleRescan = function() {
clearTimeout(window.vscNavigationRescanTimer);
- window.vscNavigationRescanTimer = setTimeout(function () {
+ window.vscNavigationRescanTimer = setTimeout(function() {
initializeWhenReady(document, true);
}, 300);
};
- ["pushState", "replaceState"].forEach(function (method) {
+ ["pushState", "replaceState"].forEach(function(method) {
if (typeof history[method] !== "function") return;
var original = history[method];
- history[method] = function () {
+ history[method] = function() {
var result = original.apply(this, arguments);
scheduleRescan();
return result;
@@ -2702,6 +2454,7 @@ function initializeNow(doc, forceReinit = false) {
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return;
var siteDisabled = applySiteRuleOverrides();
+ if (!tc.settings.enabled || siteDisabled) return;
if (!doc.body.classList.contains("vsc-initialized")) {
doc.body.classList.add("vsc-initialized");
@@ -2713,9 +2466,7 @@ function initializeNow(doc, forceReinit = false) {
if (forceReinit) {
log("Force re-initialization requested", 4);
- reapplySiteRulesAndControllerGeometry();
- } else if (!tc.settings.enabled || siteDisabled) {
- reapplySiteRulesAndControllerGeometry();
+ refreshAllControllerGeometry();
}
vscInitializedDocuments.add(doc);
@@ -2830,7 +2581,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
var controller = v.vsc.div;
const userDrivenActionsThatShowController = [
@@ -2838,6 +2589,8 @@ function runAction(action, value, e) {
"advance",
"faster",
"slower",
+ "louder",
+ "softer",
"reset",
"fast",
"move",
@@ -2850,7 +2603,14 @@ function runAction(action, value, e) {
"toggleSubtitleNudge",
"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);
}
if (v.classList.contains("vsc-cancelled")) return;
@@ -2953,6 +2713,12 @@ function runAction(action, value, e) {
case "muted":
muted(v);
break;
+ case "louder":
+ volumeUp(v, Number.isFinite(numValue) ? numValue : 0.1);
+ break;
+ case "softer":
+ volumeDown(v, Number.isFinite(numValue) ? numValue : 0.1);
+ break;
case "mark":
setMark(v);
break;
@@ -3017,17 +2783,62 @@ function resetSpeed(v, target, isFastKey = false) {
}
function muted(v) {
- v.muted = !v.muted;
+ var nextMuted = !v.muted;
+ v.muted = nextMuted;
+ if (!isOnYouTube()) return;
+ var ytApi = getYouTubePlayerApi(v);
+ if (!ytApi) return;
+ if (nextMuted && typeof ytApi.mute === "function") ytApi.mute();
+ if (!nextMuted && typeof ytApi.unMute === "function") ytApi.unMute();
}
+
+function getYouTubePlayerApi(video) {
+ if (!isOnYouTube()) return null;
+ var playerEl =
+ (video && video.closest ? video.closest(".html5-video-player") : null) ||
+ document.getElementById("movie_player") ||
+ document.querySelector(".html5-video-player");
+ if (!playerEl) return null;
+ return playerEl.wrappedJSObject || playerEl;
+}
+
+function syncYouTubePlayerVolume(video, volume) {
+ var ytApi = getYouTubePlayerApi(video);
+ if (!ytApi || typeof ytApi.setVolume !== "function") return;
+ ytApi.setVolume(Math.round(volume * 100));
+ if (volume > 0 && typeof ytApi.unMute === "function") {
+ ytApi.unMute();
+ }
+}
+
+function setVideoVolume(video, targetVolume) {
+ var nextVolume = Math.max(0, Math.min(1, Number(targetVolume.toFixed(2))));
+ video.volume = nextVolume;
+ if (nextVolume > 0 && video.muted) {
+ video.muted = false;
+ }
+ syncYouTubePlayerVolume(video, nextVolume);
+}
+
+function volumeUp(v, value) {
+ setVideoVolume(v, v.volume + value);
+}
+
+function volumeDown(v, value) {
+ setVideoVolume(v, v.volume - value);
+}
+
function setMark(v) {
v.vsc.mark = v.currentTime;
}
+
function jumpToMark(v) {
if (v.vsc && typeof v.vsc.mark === "number") {
extendSpeedRestoreWindow(v);
v.currentTime = v.vsc.mark;
}
}
+
function handleDrag(video, e) {
const c = video.vsc.div;
const sC = convertControllerToManualPosition(video.vsc);
@@ -3037,7 +2848,7 @@ function handleDrag(video, e) {
pE.parentNode &&
pE.parentNode.offsetHeight === pE.offsetHeight &&
pE.parentNode.offsetWidth === pE.offsetWidth
- )
+ )
pE = pE.parentNode;
video.classList.add("vcs-dragging");
sC.classList.add("dragging");
@@ -3066,6 +2877,7 @@ function handleDrag(video, e) {
pE.addEventListener("mouseleave", eD);
pE.addEventListener("mousemove", sD);
}
+
function showController(controller, duration = 2000, forced = false) {
if (!controller || typeof controller.classList === "undefined") return;
var restoreHidden =
@@ -3083,7 +2895,7 @@ function showController(controller, duration = 2000, forced = false) {
clearTimeout(controller.showTimeOut);
}
- controller.showTimeOut = setTimeout(function () {
+ controller.showTimeOut = setTimeout(function() {
controller.classList.remove("vsc-show");
controller.classList.remove("vsc-forced-show");
if (controller.restoreHiddenAfterShow === true) {
diff --git a/manifest.json b/manifest.json
index fc70a61..5307d46 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
- "version": "5.1.7",
+ "version": "5.2.1",
"manifest_version": 2,
"description": "Speed up, slow down, advance and rewind HTML5 audio/video with shortcuts (New and improved version of \"Video Speed Controller\")",
"homepage_url": "https://github.com/SoPat712/speeder",
@@ -59,7 +59,9 @@
"inject.css"
],
"js": [
- "settings-core.js",
+ "shared/controller-utils.js",
+ "shared/key-bindings.js",
+ "shared/site-rules.js",
"ui-icons.js",
"inject.js"
]
diff --git a/options.css b/options.css
index 9119cb5..e4c16a9 100644
--- a/options.css
+++ b/options.css
@@ -7,6 +7,17 @@
--text: #17191c;
--muted: #626b76;
--accent: #111827;
+ --switch-track-off: #c1cad6;
+ --switch-track-off-border: #aeb8c5;
+ --switch-track-on: #111827;
+ --switch-track-on-border: #111827;
+ --switch-thumb-off: #ffffff;
+ --switch-thumb-on: #ffffff;
+ --toggle-open-fg: #111827;
+ --toggle-open-bg: #eef2f6;
+ --toggle-open-border: #c5ccd5;
+ --toggle-open-hover-bg: #e4eaf1;
+ --toggle-open-hover-border: #b5c0cc;
--danger: #b42318;
}
@@ -210,6 +221,7 @@ button:active {
}
button:focus-visible,
+input[type="checkbox"]:focus-visible,
input[type="text"]:focus,
select:focus,
textarea:focus {
@@ -247,10 +259,49 @@ textarea:focus {
}
input[type="checkbox"] {
- width: 16px;
- height: 16px;
- margin: 2px 0 0;
- accent-color: var(--accent);
+ appearance: none;
+ -webkit-appearance: none;
+ position: relative;
+ 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 {
@@ -322,6 +373,39 @@ label em {
.toggle-site-rule {
font-weight: 400;
+ color: var(--muted);
+}
+
+.toggle-site-rule:hover {
+ color: var(--toggle-open-fg);
+ background: var(--toggle-open-hover-bg);
+ border-color: var(--toggle-open-hover-border);
+}
+
+.toggle-site-rule .site-rule-toggle-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ pointer-events: none;
+}
+
+.toggle-site-rule .site-rule-toggle-icon svg {
+ width: 18px;
+ height: 18px;
+}
+
+.site-rule:not(.collapsed) .toggle-site-rule {
+ color: var(--toggle-open-fg);
+ background: var(--toggle-open-bg);
+ border-color: var(--toggle-open-border);
+}
+
+.site-rule:not(.collapsed) .toggle-site-rule:hover {
+ color: var(--toggle-open-fg);
+ background: var(--toggle-open-hover-bg);
+ border-color: var(--toggle-open-hover-border);
}
.row {
@@ -339,7 +423,7 @@ label em {
}
.row.row-checkbox {
- grid-template-columns: minmax(0, 1fr) 24px;
+ grid-template-columns: minmax(0, 1fr) auto;
}
.row.row-checkbox input[type="checkbox"] {
@@ -403,9 +487,9 @@ label em {
.site-override-lead {
display: grid;
- grid-template-columns: minmax(0, 1fr) 24px;
+ grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
- align-items: flex-start;
+ align-items: center;
font-weight: 600;
margin-bottom: 8px;
cursor: pointer;
@@ -414,7 +498,6 @@ label em {
.site-override-lead input[type="checkbox"] {
justify-self: end;
- margin-top: 3px;
}
.site-override-lead span {
@@ -427,10 +510,19 @@ label em {
.site-rule-override-section .site-autohide-container,
.site-rule-override-section .site-playback-container,
.site-rule-override-section .site-opacity-container,
-.site-rule-override-section .site-subtitleNudge-container {
+.site-rule-override-section .site-subtitleNudge-container,
+.site-controlbar-container,
+.site-popup-controlbar-container,
+.site-shortcuts-container {
padding-left: 4px;
}
+.site-override-disabled {
+ opacity: 0.48;
+ pointer-events: none;
+ user-select: none;
+}
+
.cb-editor {
display: flex;
flex-direction: column;
@@ -803,7 +895,7 @@ button.lucide-result-tile.lucide-picked {
}
.site-rule-option-checkbox {
- grid-template-columns: minmax(0, 1fr) 24px;
+ grid-template-columns: minmax(0, 1fr) auto;
}
.site-rule-option-checkbox > input[type="checkbox"] {
@@ -833,7 +925,7 @@ button.lucide-result-tile.lucide-picked {
.site-rule-split-label {
display: grid;
- grid-template-columns: minmax(0, 1fr) 24px;
+ grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: flex-start;
width: 100%;
@@ -845,7 +937,7 @@ button.lucide-result-tile.lucide-picked {
.site-rule-split-label input[type="checkbox"] {
justify-self: end;
- margin-top: 3px;
+ margin-top: 0;
}
.site-rule-option-checkbox > .site-rule-split-label {
@@ -889,8 +981,8 @@ button.lucide-result-tile.lucide-picked {
.force-label {
display: flex;
- align-items: flex-start;
- gap: 8px;
+ align-items: center;
+ gap: 10px;
width: auto;
margin: 0;
color: var(--muted);
@@ -898,7 +990,7 @@ button.lucide-result-tile.lucide-picked {
}
.force-label input {
- margin-top: 2px;
+ margin-top: 0;
}
.action-row {
@@ -956,7 +1048,7 @@ button.lucide-result-tile.lucide-picked {
}
.site-override-lead {
- grid-template-columns: minmax(0, 1fr) 24px;
+ grid-template-columns: minmax(0, 1fr) auto;
}
.action-row button,
@@ -1005,6 +1097,17 @@ button.lucide-result-tile.lucide-picked {
--text: #f2f4f6;
--muted: #a0a8b2;
--accent: #f2f4f6;
+ --switch-track-off: #374151;
+ --switch-track-off-border: #4b5563;
+ --switch-track-on: #aab7c6;
+ --switch-track-on-border: #aab7c6;
+ --switch-thumb-off: #f8fafc;
+ --switch-thumb-on: #111315;
+ --toggle-open-fg: #f2f4f6;
+ --toggle-open-bg: #2b3138;
+ --toggle-open-border: #4b5563;
+ --toggle-open-hover-bg: #374151;
+ --toggle-open-hover-border: #64748b;
--danger: #ff8a80;
}
diff --git a/options.html b/options.html
index 4152b0d..189fddf 100644
--- a/options.html
+++ b/options.html
@@ -5,10 +5,13 @@