feat: expand shortcuts and improve YouTube navigation

This commit is contained in:
2026-07-26 10:19:11 -04:00
parent 04f1414118
commit a2d7f0d9d6
11 changed files with 224 additions and 81 deletions
+25 -9
View File
@@ -159,8 +159,8 @@ var tc = {
lastInteractedMedia: null
};
var MIN_SPEED = 0.0625;
var MAX_SPEED = 16;
var MIN_SPEED = Number(keyBindingUtils.MIN_SPEED) || 0.01;
var MAX_SPEED = Number(keyBindingUtils.MAX_SPEED) || 100;
var YT_NATIVE_MIN = 0.25;
var YT_NATIVE_MAX = 2.0;
var YT_NATIVE_STEP = 0.05;
@@ -652,6 +652,7 @@ function normalizeStoredBinding(binding, fallbackCode) {
var normalized = {
action: binding.action,
code: normalizedCode,
shiftKey: binding.shiftKey === true,
disabled: false,
value: keyBindingUtils.sanitizeActionValue(
binding.action,
@@ -1360,7 +1361,8 @@ function matchesKeyBinding(binding, event) {
binding.disabled !== true &&
typeof binding.code === "string" &&
binding.code.length > 0 &&
binding.code === event.code
binding.code === event.code &&
(binding.shiftKey === true) === event.shiftKey
);
}
@@ -2564,6 +2566,9 @@ function loadInitialRuntimeSettings(attempt) {
});
}
// Install before async settings hydration so SPA-owned window capture handlers
// cannot hide later key events from Speeder.
attachKeydownListeners(document);
loadInitialRuntimeSettings(0);
function getKeyBindings(action, what = "value") {
@@ -4208,7 +4213,7 @@ function setupListener(root) {
if (config.skipResetDisarm !== true) {
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.01)
video.vsc.speedIndicator.textContent = speed.toFixed(2);
video.vsc.targetSpeed = speed;
video.vsc.targetSpeedSourceKey = getVideoSourceKey(video);
@@ -4240,7 +4245,7 @@ function setupListener(root) {
if (!video || typeof video.playbackRate === "undefined" || !video.vsc)
return;
if (shouldIgnoreSuppressedRateChange(video)) return;
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.0625)
var currentSpeed = video.playbackRate; // Preserve full precision (e.g. 0.01)
var pendingRateChange = takePendingRateChange(video, currentSpeed);
if (tc.settings.forceLastSavedSpeed) {
if (pendingRateChange) {
@@ -4354,12 +4359,13 @@ function inIframe() {
function attachKeydownListeners(doc) {
// Content scripts already run in every frame. Keeping each listener scoped
// to its own document avoids duplicate shortcuts and stale iframe ownership.
// to its own frame avoids duplicate shortcuts and stale iframe ownership.
var docs = [doc];
docs.forEach(function(keyDoc) {
if (keyDoc.vscKeydownListenerAttached) return;
keyDoc.addEventListener(
var keyTarget = keyDoc.defaultView || keyDoc;
if (keyTarget.vscKeydownListenerAttached) return;
keyTarget.addEventListener(
"keydown",
function(event) {
if (
@@ -4411,7 +4417,7 @@ function attachKeydownListeners(doc) {
},
true
);
keyDoc.vscKeydownListenerAttached = true;
keyTarget.vscKeydownListenerAttached = true;
});
}
@@ -4816,6 +4822,16 @@ function resolveActionMediaTargets(event, specificVideo) {
if (tc.settings.shortcutTargetMode === "all") return candidates;
if (
isOnYouTube() &&
/^\/(watch|live)(\/|$)/.test(location.pathname)
) {
var youtubeMain = candidates.find(function(video) {
return video.closest && video.closest("#movie_player");
});
if (youtubeMain) return [youtubeMain];
}
var pointer =
tc.lastPointerPosition && tc.lastPointerPosition.document === docContext
? tc.lastPointerPosition
+2 -10
View File
@@ -54,11 +54,7 @@ function normalizedSettingsForExport(rawStorage) {
// lastSpeed is useful user data, but it is intentionally outside the
// managed options diff because content scripts update it at runtime.
if (Object.prototype.hasOwnProperty.call(raw, "lastSpeed")) {
if (
Number.isFinite(expanded.lastSpeed) &&
expanded.lastSpeed >= 0.0625 &&
expanded.lastSpeed <= 16
) {
if (Number.isFinite(expanded.lastSpeed)) {
normalized.lastSpeed = expanded.lastSpeed;
}
}
@@ -117,11 +113,7 @@ function persistImportedSyncSettings(currentRaw, importedRaw, callback) {
importedRaw &&
Object.prototype.hasOwnProperty.call(importedRaw, "lastSpeed")
) {
if (
Number.isFinite(expanded.lastSpeed) &&
expanded.lastSpeed >= 0.0625 &&
expanded.lastSpeed <= 16
) {
if (Number.isFinite(expanded.lastSpeed)) {
mutation.set.lastSpeed = expanded.lastSpeed;
}
}
+4 -4
View File
@@ -250,7 +250,7 @@
</div>
</div>
<select id="addShortcutSelector">
<select id="addShortcutSelector" aria-label="Add another shortcut">
<option value="">Add shortcut&hellip;</option>
</select>
</section>
@@ -843,7 +843,7 @@
</div>
<div class="site-rule-option site-rule-option-field">
<label
>Preferred speed (0.0625&ndash;16):<br /><em
>Preferred speed (0.01&ndash;100):<br /><em
>Overrides the Preferred speed action for matching
URLs. It does not force every video to start at this
speed.</em
@@ -1022,10 +1022,10 @@
<section class="settings-card action-card">
<div class="section-heading">
<h3>Actions</h3>
<p class="section-intro">Save, restore, export, or import settings.</p>
<p class="section-intro">Changes save automatically.</p>
</div>
<div class="action-row">
<button id="save">Save Changes</button>
<button id="save">Save Now</button>
<button id="restore">Restore Defaults</button>
<button id="exportSettings">Export Settings</button>
<button id="importSettings">Import Settings</button>
+64 -46
View File
@@ -164,9 +164,32 @@ function createDefaultBinding(action, code, value) {
var tcDefaults = vscGetSettingsDefaults();
var optionsSyncSettingsLoaded = false;
var autoSaveTimer = null;
function scheduleAutoSave() {
if (!optionsSyncSettingsLoaded) return;
clearTimeout(autoSaveTimer);
var sourceSaveButton = document.getElementById("save");
var scheduledTimer = setTimeout(function () {
if (
autoSaveTimer !== scheduledTimer ||
!sourceSaveButton ||
!sourceSaveButton.isConnected
) {
return;
}
autoSaveTimer = null;
save_options();
}, 300);
autoSaveTimer = scheduledTimer;
}
function setOptionsSyncSettingsLoaded(loaded) {
optionsSyncSettingsLoaded = loaded === true;
if (!optionsSyncSettingsLoaded) {
clearTimeout(autoSaveTimer);
autoSaveTimer = null;
}
var saveButton = document.getElementById("save");
if (saveButton) {
saveButton.disabled = !optionsSyncSettingsLoaded;
@@ -302,33 +325,15 @@ function refreshAddShortcutSelector() {
selector.remove(1);
}
// Find all currently used actions
const usedActions = new Set();
document.querySelectorAll(".shortcut-row").forEach((row) => {
const action = row.dataset.action;
if (action) {
usedActions.add(action);
}
});
// Add all unused actions
Object.keys(actionLabels).forEach((action) => {
if (!usedActions.has(action)) {
const option = document.createElement("option");
option.value = action;
option.text = actionLabels[action];
selector.appendChild(option);
}
const option = document.createElement("option");
option.value = action;
option.text = actionLabels[action];
selector.appendChild(option);
});
// If no available actions, hide or disable the selector
if (selector.options.length === 1) {
selector.disabled = true;
selector.options[0].text = "All shortcuts added";
} else {
selector.disabled = false;
selector.options[0].text = "Add shortcut\u2026";
}
selector.disabled = false;
selector.options[0].text = "Add shortcut\u2026";
}
function refreshSiteRuleAddShortcutSelector(ruleEl) {
@@ -340,32 +345,19 @@ function refreshSiteRuleAddShortcutSelector(ruleEl) {
selector.remove(1);
}
var usedActions = new Set();
ruleEl.querySelectorAll(".site-shortcuts-rows .shortcut-row.customs").forEach(function (row) {
var action = row.dataset.action;
if (action) usedActions.add(action);
});
Object.keys(actionLabels).forEach(function (action) {
if (!usedActions.has(action)) {
var option = document.createElement("option");
option.value = action;
option.textContent = actionLabels[action];
selector.appendChild(option);
}
var option = document.createElement("option");
option.value = action;
option.textContent = actionLabels[action];
selector.appendChild(option);
});
var overrideShortcutsOn =
ruleEl.querySelector(".override-shortcuts") &&
ruleEl.querySelector(".override-shortcuts").checked;
if (selector.options.length === 1) {
selector.disabled = true;
selector.options[0].text = "All shortcuts added";
} else {
selector.disabled = !overrideShortcutsOn;
selector.options[0].text = "Add shortcut\u2026";
}
selector.disabled = !overrideShortcutsOn;
selector.options[0].text = "Add shortcut\u2026";
}
function getGlobalBindingSnapshotForSiteShortcut(action) {
@@ -424,7 +416,10 @@ function readOptionalPreferredSpeedInput(input) {
if (!rawValue) return undefined;
var parsed = Number(rawValue);
if (!Number.isFinite(parsed)) return undefined;
return Math.min(16, Math.max(0.0625, parsed));
return Math.min(
keyBindingUtils.MAX_SPEED,
Math.max(keyBindingUtils.MIN_SPEED, parsed)
);
}
function updateSiteRuleToggleIcon(toggleButton, action) {
@@ -560,6 +555,7 @@ function normalizeStoredBinding(binding, fallbackCode) {
var normalized = {
code: normalizedCode,
shiftKey: binding.shiftKey === true,
disabled: false
};
@@ -578,7 +574,7 @@ function formatBindingCode(code) {
function getBindingLabel(binding) {
if (!binding) return "";
if (binding.disabled) return "";
return formatBindingCode(binding.code);
return (binding.shiftKey ? "Shift+" : "") + formatBindingCode(binding.code);
}
function setShortcutInputBinding(input, binding) {
@@ -591,6 +587,7 @@ function captureBindingFromEvent(event) {
if (typeof event.code !== "string" || event.code.length === 0) return null;
return {
code: event.code,
shiftKey: event.shiftKey === true,
disabled: false
};
}
@@ -600,6 +597,7 @@ function recordKeyPress(event) {
if (event.key === "Backspace") {
setShortcutInputBinding(event.target, null);
scheduleAutoSave();
event.preventDefault();
event.stopPropagation();
return;
@@ -607,6 +605,7 @@ function recordKeyPress(event) {
if (event.key === "Escape") {
setShortcutInputBinding(event.target, createDisabledBinding());
scheduleAutoSave();
event.preventDefault();
event.stopPropagation();
return;
@@ -616,6 +615,7 @@ function recordKeyPress(event) {
if (!binding) return;
setShortcutInputBinding(event.target, binding);
scheduleAutoSave();
event.preventDefault();
event.stopPropagation();
}
@@ -770,6 +770,7 @@ function createKeyBindings(item) {
keyBindings.push({
action: action,
code: binding.code,
shiftKey: binding.shiftKey === true,
disabled: binding.disabled === true,
value: bindingValue,
force: false,
@@ -807,6 +808,8 @@ function validate() {
}
function save_options() {
clearTimeout(autoSaveTimer);
autoSaveTimer = null;
var status = document.getElementById("status");
if (!optionsSyncSettingsLoaded) {
status.textContent =
@@ -1092,6 +1095,7 @@ function save_options() {
shortcuts.push({
action: action,
code: binding.code,
shiftKey: binding.shiftKey === true,
disabled: binding.disabled === true,
value: shortcutValue,
force: forceCheckbox ? forceCheckbox.checked : false
@@ -1551,6 +1555,7 @@ function initControlBarEditor() {
var zone = e.target.closest(".cb-dropzone");
if (zone) {
e.preventDefault();
scheduleAutoSave();
}
clearControlBarDropTargets(null);
@@ -1825,13 +1830,16 @@ function restore_options(callback) {
document.querySelectorAll(".customs:not([id])").forEach((row) => row.remove());
var usedDefaultShortcutRows = new Set();
storage.keyBindings.forEach((item) => {
var row = document.getElementById(item.action);
var normalizedBinding = normalizeStoredBinding(item);
if (!row) {
if (!row || usedDefaultShortcutRows.has(item.action)) {
add_shortcut(item.action, item.value);
row = document.querySelector(".shortcut-row.customs:last-of-type");
} else {
usedDefaultShortcutRows.add(item.action);
}
if (!row) return;
@@ -2030,11 +2038,13 @@ document.addEventListener("DOMContentLoaded", function () {
if (siteRuleForShortcut) {
refreshSiteRuleAddShortcutSelector(siteRuleForShortcut);
}
scheduleAutoSave();
return;
}
var removeSiteRuleButton = targetEl.closest(".remove-site-rule");
if (removeSiteRuleButton) {
removeSiteRuleButton.closest(".site-rule").remove();
scheduleAutoSave();
return;
}
var toggleButton = targetEl.closest(".toggle-site-rule");
@@ -2104,4 +2114,12 @@ document.addEventListener("DOMContentLoaded", function () {
}
}
});
document.addEventListener("change", (event) => {
if (
event.target.id !== "addShortcutSelector" &&
!event.target.closest("#lucideIconSettings")
) {
scheduleAutoSave();
}
});
});
+9 -4
View File
@@ -8,6 +8,9 @@
root.SpeederShared = root.SpeederShared || {};
root.SpeederShared.keyBindings = exports;
})(typeof globalThis !== "undefined" ? globalThis : this, function() {
var MIN_SPEED = 0.01;
var MAX_SPEED = 100;
function normalizeBindingKey(key) {
if (typeof key !== "string" || key.length === 0) return null;
if (key === "Spacebar") return " ";
@@ -127,15 +130,15 @@
if (!Number.isFinite(numericValue)) return "must be a finite number";
if (
(action === "slower" || action === "faster") &&
(numericValue <= 0 || numericValue > 16)
(numericValue <= 0 || numericValue > MAX_SPEED)
) {
return "must be greater than 0 and no more than 16";
return "must be greater than 0 and no more than " + MAX_SPEED;
}
if (
action === "fast" &&
(numericValue < 0.0625 || numericValue > 16)
(numericValue < MIN_SPEED || numericValue > MAX_SPEED)
) {
return "must be between 0.0625 and 16";
return "must be between " + MIN_SPEED + " and " + MAX_SPEED;
}
if (
(action === "rewind" || action === "advance") &&
@@ -162,6 +165,8 @@
}
return {
MAX_SPEED: MAX_SPEED,
MIN_SPEED: MIN_SPEED,
getActionValueError: getActionValueError,
getLegacyKeyCode: getLegacyKeyCode,
inferBindingCode: inferBindingCode,
+7 -5
View File
@@ -4,6 +4,8 @@
var LEGACY_SITE_RULES_DIFF_FORMAT = "defaults-diff-v1";
var SITE_RULES_DIFF_FORMAT = "defaults-diff-v2";
var REMOVED_DEFAULT_RULE_KEYS_META = "removedDefaultRuleKeys";
var MIN_SPEED = 0.01;
var MAX_SPEED = 100;
var DEFAULT_BUTTONS = ["rewind", "slower", "faster", "advance", "display"];
var LEGACY_SYNC_KEYS = [
"resetSpeed",
@@ -363,7 +365,7 @@
return clampFiniteNumber(value, 250, 1000, undefined);
}
if (key === "preferredSpeed") {
return clampFiniteNumber(value, 0.0625, 16, undefined);
return clampFiniteNumber(value, MIN_SPEED, MAX_SPEED, undefined);
}
if (key === "shortcuts" && Array.isArray(value)) {
return sanitizeStoredBindingValues(value).map(function(shortcut) {
@@ -749,9 +751,9 @@
var value = Number(normalized.value);
var invalid = !Number.isFinite(value);
if (action === "slower" || action === "faster") {
invalid = invalid || value <= 0 || value > 16;
invalid = invalid || value <= 0 || value > MAX_SPEED;
} else if (action === "fast") {
invalid = invalid || value < 0.0625 || value > 16;
invalid = invalid || value < MIN_SPEED || value > MAX_SPEED;
} else if (action === "rewind" || action === "advance") {
invalid = invalid || value < 0;
} else if (action === "louder" || action === "softer") {
@@ -885,8 +887,8 @@
expanded.lastSpeed = clampFiniteNumber(
expanded.lastSpeed,
0.0625,
16,
MIN_SPEED,
MAX_SPEED,
DEFAULT_SETTINGS.lastSpeed
);
expanded.hideWithControlsTimer = clampFiniteNumber(
+48 -1
View File
@@ -320,6 +320,7 @@ describe("inject.js media/controller lifecycle regressions", () => {
src: "blob:https://www.youtube.com/hover-preview",
mountRect: makeRect(0, 0, 320, 180)
});
preview.mount.id = "inline-preview-player";
document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 90 })
);
@@ -333,6 +334,10 @@ describe("inject.js media/controller lifecycle regressions", () => {
src: "blob:https://www.youtube.com/main-player",
mountRect: makeRect(0, 0, 1280, 720)
});
main.mount.id = "movie_player";
document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 90 })
);
document.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
@@ -342,11 +347,53 @@ describe("inject.js media/controller lifecycle regressions", () => {
})
);
expect(window.tc.lastPointerPosition).toBeNull();
expect(window.tc.lastPointerPosition).not.toBeNull();
expect(preview.video.playbackRate).toBe(1);
expect(main.video.playbackRate).toBe(1.1);
});
it("captures SPA shortcuts before later page handlers and distinguishes Shift", async () => {
vi.useFakeTimers();
bootInject({
syncGetDelayMs: 25,
syncData: {
keyBindings: [
{ action: "advance", code: "KeyX", value: 10 },
{ action: "advance", code: "KeyX", shiftKey: true, value: 3 }
]
}
});
expect(window.vscKeydownListenerAttached).toBe(true);
window.addEventListener(
"keydown",
(event) => event.stopImmediatePropagation(),
true
);
await vi.advanceTimersByTimeAsync(25);
await settleLifecycle();
const { video } = createControlledVideo();
video.currentTime = 50;
video.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
code: "KeyX",
key: "x"
})
);
video.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
code: "KeyX",
key: "X",
shiftKey: true
})
);
expect(video.currentTime).toBe(63);
});
it("skips ambient loops by default and includes them when explicitly enabled", async () => {
bootInject();
await settleLifecycle();
+42
View File
@@ -106,6 +106,48 @@ describe("options page", () => {
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
it("adds and restores duplicate actions with Shift bindings", async () => {
const chrome = await setupOptions();
const selector = document.getElementById("addShortcutSelector");
expect(selector.querySelector('option[value="rewind"]')).not.toBeNull();
selector.value = "rewind";
selector.dispatchEvent(new window.Event("change", { bubbles: true }));
const duplicate = document.querySelector(
'.shortcut-row.customs[data-action="rewind"]'
);
duplicate.querySelector(".customKey").dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "Z",
code: "KeyZ",
shiftKey: true,
bubbles: true
})
);
duplicate.querySelector(".customValue").value = "3";
globalThis.save_options();
expect(
chrome.storage.sync.__state.keyBindings.filter(
(binding) => binding.action === "rewind"
)
).toEqual([
expect.objectContaining({ value: 10, shiftKey: false }),
expect.objectContaining({ value: 3, shiftKey: true })
]);
globalThis.restore_options();
await flushAsyncWork();
expect(
document.querySelectorAll('.shortcut-row[data-action="rewind"]')
).toHaveLength(2);
expect(
document.querySelector(
'.shortcut-row.customs[data-action="rewind"] .customKey'
).value
).toBe("Shift+Z");
});
it("shows a more-menu trigger for collapsed site rules and a collapse trigger when open", async () => {
await setupOptions({ sync: { siteRules: [] } });
+16
View File
@@ -67,6 +67,22 @@ describe("options.js", () => {
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
it("automatically saves changed settings", async () => {
const chrome = bootOptions({ syncData: { rememberSpeed: false } });
await flushAsyncWork(3);
vi.useFakeTimers();
chrome.storage.sync.set.mockClear();
const rememberSpeed = document.getElementById("rememberSpeed");
rememberSpeed.checked = true;
rememberSpeed.dispatchEvent(new Event("change", { bubbles: true }));
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(chrome.storage.sync._dump().rememberSpeed).toBe(true);
});
it("does not partially save options when a required site shortcut is invalid", async () => {
const chrome = bootOptions({ syncData: { rememberSpeed: false } });
await flushAsyncWork(3);
+2 -2
View File
@@ -244,7 +244,7 @@ describe("canonical settings storage", () => {
forceLastSavedSpeed: "true",
controllerOpacity: 3,
subtitleNudgeInterval: -5,
preferredSpeed: 50
preferredSpeed: 500
}
]
});
@@ -257,7 +257,7 @@ describe("canonical settings storage", () => {
expect(rule.forceLastSavedSpeed).toBe(true);
expect(rule.controllerOpacity).toBe(1);
expect(rule.subtitleNudgeInterval).toBe(250);
expect(rule.preferredSpeed).toBe(16);
expect(rule.preferredSpeed).toBe(100);
});
it("falls back for null and blank numeric settings instead of coercing zero", () => {
+5
View File
@@ -158,6 +158,11 @@ describe("shared helpers", () => {
"NumpadAdd"
);
expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65);
expect(keyBindingUtils.getActionValueError("fast", 0.01)).toBeNull();
expect(keyBindingUtils.getActionValueError("fast", 100)).toBeNull();
expect(keyBindingUtils.getActionValueError("fast", 0.009)).toContain(
"between 0.01 and 100"
);
});
it("builds and parses import/export payloads", () => {