Compare commits

..
17 Commits
Author SHA1 Message Date
joshpatra fc45d02541 Release v6.0.5 2026-07-26 11:52:35 -04:00
joshpatra f61f1fdd97 Release v6.0.4 2026-07-26 11:23:34 -04:00
joshpatra de6598dfde chore(release): release v5.3.0 2026-05-24 23:34:23 -04:00
joshpatra 7e1678ec4b Merge pull request #34 from netjeff/main
Minor improvements to README.md
2026-04-21 10:30:27 -04:00
netjeff 113c3b2896 Minor improvements to README.md
Delete chrome store link to original upstream Video Speed Controller by igrigorik.
Also some minor formatting improvements.
2026-04-16 10:43:57 -06:00
joshpatra d6f9a03946 Release v5.2.8 2026-04-12 14:17:56 -04:00
joshpatra 014e05998d Release v5.2.7 2026-04-12 14:15:56 -04:00
joshpatra 1a7dc3097e Release v5.2.4 2026-04-10 15:04:29 -04:00
joshpatra c626aca89c Release v5.2.1 2026-04-09 16:17:27 -04:00
joshpatra 05b8456e94 release: v5.1.7 2026-04-02 22:37:10 -04:00
joshpatra 0cbfac4b82 Release v5.1.5 2026-04-02 18:08:33 -04:00
joshpatra b3707c0803 Release v5.1.4 2026-04-02 18:07:09 -04:00
joshpatra fb25c56230 Merge beta 2026-04-01 15:33:11 -04:00
joshpatra 4efc3e0acc Merge beta 2026-04-01 11:29:16 -04:00
joshpatra 7c0a188cd3 Merge beta 2026-04-01 11:19:08 -04:00
joshpatra d7ce1fd000 Merge beta 2026-03-31 15:04:56 -04:00
joshpatra 313832015b Merge beta 2026-03-31 15:01:18 -04:00
14 changed files with 374 additions and 92 deletions
+48 -12
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.1;
var MAX_SPEED = Number(keyBindingUtils.MAX_SPEED) || 16;
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,
@@ -733,6 +734,13 @@ function getRememberedSpeed(video) {
var videoSpeed = tc.settings.speeds[sourceKey];
if (isValidSpeed(videoSpeed)) return videoSpeed;
}
// No per-source entry yet — fall back to the most recently used speed.
// This ensures e.g. YouTube Shorts carry the user's chosen speed to the
// next Short even when that exact source URL hasn't been visited before.
if (isValidSpeed(tc.settings.lastSpeed)) {
return tc.settings.lastSpeed;
}
return null;
}
@@ -1353,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
);
}
@@ -2557,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") {
@@ -3745,7 +3757,7 @@ function defineVideoController() {
// Only hide if the video is not paused
// (Many players keep controls visible while paused)
// However, the user said "Reveal on every mouse and keyboard input"
// and "auto-hidden after timespan".
// and "auto-hidden after timespan".
// We'll follow the timer strictly.
wrapper.classList.add("vsc-idle-hidden");
log("Generic hide: controller hidden due to inactivity", 5);
@@ -4201,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);
@@ -4233,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) {
@@ -4302,7 +4314,11 @@ function clearPendingInitialization(doc) {
function tryInitializeDocument(doc, forceReinit) {
if (!doc) return false;
if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) {
if (
!tc.runtimeSettingsHydrated ||
(!forceReinit && vscInitializedDocuments.has(doc)) ||
!doc.body
) {
return false;
}
@@ -4347,12 +4363,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 (
@@ -4404,7 +4421,7 @@ function attachKeydownListeners(doc) {
},
true
);
keyDoc.vscKeydownListenerAttached = true;
keyTarget.vscKeydownListenerAttached = true;
});
}
@@ -4550,7 +4567,16 @@ function attachMediaDetectionListeners(root) {
}
function scheduleNavigationRescan() {
window.vscLastObservedHref = location.href;
var nextHref = location.href;
if (window.vscLastObservedHref !== nextHref) {
// Pointer and controller interaction state belongs to the previous route.
// YouTube keeps hover-preview and Shorts players connected across SPA
// navigations, so retaining either target can send shortcuts to an
// off-screen/stale player even though the new watch controller works.
tc.lastPointerPosition = null;
tc.lastInteractedMedia = null;
}
window.vscLastObservedHref = nextHref;
clearTimeout(window.vscNavigationRescanTimer);
window.vscNavigationRescanTimer = setTimeout(function() {
initializeWhenReady(document, true);
@@ -4800,6 +4826,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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "Speeder",
"short_name": "Speeder",
"version": "6.0.0",
"version": "6.0.5",
"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",
+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;
}
}
+15 -5
View File
@@ -322,7 +322,7 @@ label em {
.shortcut-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px 120px;
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
gap: 12px;
align-items: center;
padding: 10px 0;
@@ -334,10 +334,6 @@ label em {
border-top: 0;
}
.shortcut-row.customs {
grid-template-columns: minmax(0, 1fr) 120px 120px 38px;
}
.shortcut-label {
color: var(--text);
font-weight: 500;
@@ -1061,6 +1057,20 @@ button.lucide-result-tile.lucide-picked {
font-weight: 500;
}
#status:not(:empty) {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1000;
min-height: 0;
margin: 0;
padding: 10px 14px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: var(--panel);
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.16);
}
#status:empty {
display: none;
}
+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.1&ndash;16):<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>
+69 -49
View File
@@ -164,9 +164,33 @@ function createDefaultBinding(action, code, value) {
var tcDefaults = vscGetSettingsDefaults();
var optionsSyncSettingsLoaded = false;
var autoSaveTimer = null;
var saveStatusTimer = 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(true);
}, 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 +326,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 +346,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 +417,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 +556,7 @@ function normalizeStoredBinding(binding, fallbackCode) {
var normalized = {
code: normalizedCode,
shiftKey: binding.shiftKey === true,
disabled: false
};
@@ -578,7 +575,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 +588,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 +598,7 @@ function recordKeyPress(event) {
if (event.key === "Backspace") {
setShortcutInputBinding(event.target, null);
scheduleAutoSave();
event.preventDefault();
event.stopPropagation();
return;
@@ -607,6 +606,7 @@ function recordKeyPress(event) {
if (event.key === "Escape") {
setShortcutInputBinding(event.target, createDisabledBinding());
scheduleAutoSave();
event.preventDefault();
event.stopPropagation();
return;
@@ -616,6 +616,7 @@ function recordKeyPress(event) {
if (!binding) return;
setShortcutInputBinding(event.target, binding);
scheduleAutoSave();
event.preventDefault();
event.stopPropagation();
}
@@ -770,6 +771,7 @@ function createKeyBindings(item) {
keyBindings.push({
action: action,
code: binding.code,
shiftKey: binding.shiftKey === true,
disabled: binding.disabled === true,
value: bindingValue,
force: false,
@@ -806,7 +808,10 @@ function validate() {
return valid;
}
function save_options() {
function save_options(isAutoSave) {
clearTimeout(autoSaveTimer);
clearTimeout(saveStatusTimer);
autoSaveTimer = null;
var status = document.getElementById("status");
if (!optionsSyncSettingsLoaded) {
status.textContent =
@@ -1092,6 +1097,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
@@ -1117,8 +1123,8 @@ function save_options() {
status.textContent = "Error: Unable to save options - " + error.message;
return;
}
status.textContent = "Options saved";
setTimeout(function () {
status.textContent = isAutoSave === true ? "Auto-saved" : "Options saved";
saveStatusTimer = setTimeout(function () {
status.textContent = "";
}, 1000);
});
@@ -1551,6 +1557,7 @@ function initControlBarEditor() {
var zone = e.target.closest(".cb-dropzone");
if (zone) {
e.preventDefault();
scheduleAutoSave();
}
clearControlBarDropTargets(null);
@@ -1825,13 +1832,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 +2040,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 +2116,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.1;
var MAX_SPEED = 16;
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.1;
var MAX_SPEED = 16;
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(
+123
View File
@@ -309,6 +309,129 @@ describe("inject.js media/controller lifecycle regressions", () => {
expect(second.video.playbackRate).toBe(1.2);
});
it("drops a stale hover-preview shortcut target after SPA navigation", async () => {
bootInject({
url: "https://www.youtube.com/",
path: "/"
});
await settleLifecycle();
const preview = createControlledVideo({
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 })
);
expect(window.tc.lastPointerPosition).toEqual(
expect.objectContaining({ document, x: 100, y: 90 })
);
window.history.pushState({}, "", "/watch?v=next-video");
const main = createControlledVideo({
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,
cancelable: true,
code: "KeyD",
key: "d"
})
);
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("finishes a forced SPA initialization after settings hydration", async () => {
vi.useFakeTimers();
bootInject({
url: "https://www.youtube.com/",
syncGetDelayMs: 1000
});
const mount = document.createElement("div");
const video = document.createElement("video");
const rect = makeRect(0, 0, 1280, 720);
mount.id = "movie_player";
video.src = "blob:https://www.youtube.com/main-player";
setRect(mount, rect);
setBoxMetrics(mount, rect.width, rect.height);
setRect(video, rect);
mount.appendChild(video);
document.body.appendChild(mount);
document.dispatchEvent(new Event("speeder-location-changed"));
await vi.advanceTimersByTimeAsync(300);
expect(window.tc.runtimeSettingsHydrated).toBe(false);
expect(video.vsc).toBeUndefined();
await vi.advanceTimersByTimeAsync(700);
await settleLifecycle();
expect(window.tc.runtimeSettingsHydrated).toBe(true);
expect(video.vsc).toBeTruthy();
video.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
code: "KeyD",
key: "d"
})
);
expect(video.playbackRate).toBe(1.1);
});
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: [] } });
+17
View File
@@ -67,6 +67,23 @@ 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);
expect(document.getElementById("status").textContent).toBe("Auto-saved");
});
it("does not partially save options when a required site shortcut is invalid", async () => {
const chrome = bootOptions({ syncData: { rememberSpeed: false } });
await flushAsyncWork(3);
+1 -1
View File
@@ -244,7 +244,7 @@ describe("canonical settings storage", () => {
forceLastSavedSpeed: "true",
controllerOpacity: 3,
subtitleNudgeInterval: -5,
preferredSpeed: 50
preferredSpeed: 500
}
]
});
+8
View File
@@ -158,6 +158,14 @@ describe("shared helpers", () => {
"NumpadAdd"
);
expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65);
expect(keyBindingUtils.getActionValueError("fast", 0.1)).toBeNull();
expect(keyBindingUtils.getActionValueError("fast", 16)).toBeNull();
expect(keyBindingUtils.getActionValueError("fast", 0.099)).toContain(
"between 0.1 and 16"
);
expect(keyBindingUtils.getActionValueError("fast", 16.001)).toContain(
"between 0.1 and 16"
);
});
it("builds and parses import/export payloads", () => {
+28 -1
View File
@@ -72,7 +72,7 @@ describe("durable playback-speed intent", () => {
expect(window.tc.settings.rememberSpeed).toBe(true);
});
it("does not seed a brand-new Short from unrelated global lastSpeed", async () => {
it("seeds a brand-new Short from lastSpeed when remember is active", async () => {
bootInject(
{ lastSpeed: 1.8 },
"https://www.youtube.com/shorts/new-source"
@@ -80,6 +80,33 @@ describe("durable playback-speed intent", () => {
const video = await createVideo();
expect(window.tc.settings.rememberSpeed).toBe(true);
expect(window.getRememberedSpeed(video)).toBe(1.8);
expect(video.playbackRate).toBe(1.8);
});
it("prefers per-source speed over lastSpeed fallback", async () => {
bootInject(
{ lastSpeed: 2.0, rememberSpeed: true },
"https://example.org/watch",
{
rememberedSpeeds: {
"https://example.org/a.mp4": { speed: 1.4, updatedAt: 100 }
}
}
);
const video = await createVideo();
expect(window.getRememberedSpeed(video)).toBe(1.4);
expect(video.playbackRate).toBe(1.4);
});
it("does not fall back to lastSpeed when remember is off", async () => {
bootInject(
{ lastSpeed: 1.8, rememberSpeed: false },
"https://example.org/watch"
);
const video = await createVideo();
expect(window.getRememberedSpeed(video)).toBeNull();
expect(video.playbackRate).toBe(1);
});