From 014e05998d3d2dfe8866385a0c62934d75149554 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sun, 12 Apr 2026 14:15:56 -0400 Subject: [PATCH] Release v5.2.7 --- inject.js | 21 ++- manifest.json | 2 +- options.css | 59 ++++++- options.html | 245 +++++++++++++++++++++++++----- options.js | 167 +++++++++++++------- popup.css | 65 +++++++- popup.html | 63 +++++++- popup.js | 13 +- shared/site-rules.js | 18 +++ tests/inject.test.js | 22 +++ tests/options.integration.test.js | 26 ++++ tests/popup.integration.test.js | 16 ++ tests/shared.test.js | 14 ++ 13 files changed, 608 insertions(+), 123 deletions(-) diff --git a/inject.js b/inject.js index 386042f..010010b 100644 --- a/inject.js +++ b/inject.js @@ -969,8 +969,8 @@ 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) { + applySiteRuleOverrides(); + if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) { return null; } refreshAllControllerGeometry(); @@ -2016,6 +2016,7 @@ function defineVideoController() { function applySiteRuleOverrides() { resetSettingsFromSiteRuleBase(); + tc.activeSiteRule = null; if (!Array.isArray(tc.settings.siteRules) || tc.settings.siteRules.length === 0) { return false; @@ -2024,7 +2025,9 @@ function applySiteRuleOverrides() { var currentUrl = location.href; var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules); - if (!matchedRule) return false; + if (!matchedRule) { + return false; + } tc.activeSiteRule = matchedRule; log(`Matched site rule: ${matchedRule.pattern}`, 4); @@ -2104,8 +2107,10 @@ function refreshAllControllerGeometry() { /** Re-match site rules for current URL and refresh controller position/opacity on every video. */ function reapplySiteRulesAndControllerGeometry() { - var siteDisabled = applySiteRuleOverrides(); - if (!tc.settings.enabled || siteDisabled) return; + applySiteRuleOverrides(); + if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) { + return; + } refreshAllControllerGeometry(); } @@ -2453,8 +2458,10 @@ function attachNavigationListeners() { function initializeNow(doc, forceReinit = false) { if ((!forceReinit && vscInitializedDocuments.has(doc)) || !doc.body) return; - var siteDisabled = applySiteRuleOverrides(); - if (!tc.settings.enabled || siteDisabled) return; + applySiteRuleOverrides(); + if (!siteRuleUtils.isSpeederActiveForSite(tc.settings.enabled, tc.activeSiteRule)) { + return; + } if (!doc.body.classList.contains("vsc-initialized")) { doc.body.classList.add("vsc-initialized"); diff --git a/manifest.json b/manifest.json index cfee6d8..78feb0d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Speeder", "short_name": "Speeder", - "version": "5.2.4", + "version": "5.2.7.0", "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", diff --git a/options.css b/options.css index 871ad93..411cd2d 100644 --- a/options.css +++ b/options.css @@ -343,11 +343,40 @@ label em { font-weight: 500; } +.shortcut-label em { + display: block; + margin-top: 4px; + color: var(--muted); + font-style: normal; + font-weight: 400; +} + .customKey, .customValue { text-align: center; } +/* Chevron: native menu indicator is often missing with themed controls */ +#addShortcutSelector, +.site-add-shortcut-selector { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-color: var(--panel); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%234b5563' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 16px 16px; + padding-right: 38px; + cursor: pointer; +} + +#addShortcutSelector:disabled, +.site-add-shortcut-selector:disabled { + cursor: not-allowed; + opacity: 0.72; +} + #addShortcutSelector { width: min(220px, 100%); margin-top: 12px; @@ -489,7 +518,7 @@ label em { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; - align-items: center; + align-items: start; font-weight: 600; margin-bottom: 8px; cursor: pointer; @@ -502,6 +531,11 @@ label em { .site-override-lead span { margin: 0; + font-weight: 600; +} + +.site-override-lead span em { + font-weight: 400; } .site-rule-override-section .site-override-fields, @@ -935,6 +969,10 @@ button.lucide-result-tile.lucide-picked { color: var(--text); } +.site-rule-split-label span em { + font-weight: 400; +} + .site-rule-split-label input[type="checkbox"] { justify-self: end; margin-top: 0; @@ -969,16 +1007,22 @@ button.lucide-result-tile.lucide-picked { } .site-shortcuts-container .shortcut-row { - grid-template-columns: minmax(0, 1fr) 110px 110px minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) 110px 110px minmax(0, 1fr) 38px; padding: 8px 0; border-top: 1px solid var(--border); } -.site-shortcuts-container .shortcut-row:first-child { +.site-shortcuts-rows .shortcut-row:first-child { padding-top: 0; border-top: 0; } +.site-add-shortcut-selector { + width: min(220px, 100%); + align-self: flex-start; + margin-top: 0; +} + .force-label { display: flex; align-items: center; @@ -1120,7 +1164,8 @@ button.lucide-result-tile.lucide-picked { } .action-row button, - #addShortcutSelector { + #addShortcutSelector, + .site-add-shortcut-selector { width: 100%; } @@ -1204,6 +1249,12 @@ button.lucide-result-tile.lucide-picked { background: rgba(255, 255, 255, 0.04); } + #addShortcutSelector, + .site-add-shortcut-selector { + background-color: var(--panel); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + } + input[type="text"]:focus, select:focus, textarea:focus { diff --git a/options.html b/options.html index c3863af..d78b791 100644 --- a/options.html +++ b/options.html @@ -96,7 +96,11 @@

Shortcuts

-

Backspace clears a shortcut. Escape disables it.

+

+ Backspace clears a key. Escape disables optional shortcuts. If a site + steals a shortcut, use a site rule with Override shortcuts (and + per-key blocking) for that URL. +

@@ -132,7 +136,10 @@ />
-
Decrease speed
+
+ Decrease speed + Required: Speeder needs a key for this action. +
-
Increase speed
+
+ Increase speed + Required: Speeder needs a key for this action. +
General
- +
- +
@@ -265,7 +286,14 @@

Playback

- +
@@ -283,11 +311,23 @@

Controller

- +
- +
- +
@@ -416,11 +462,23 @@

- +
- +
@@ -568,19 +626,36 @@
- +
- +
- +
- +
- +
- +
- +
- +
- +
@@ -709,12 +863,22 @@
- +
@@ -731,10 +895,23 @@
-
+
+
+ +
diff --git a/options.js b/options.js index 7bb5096..7d2295d 100644 --- a/options.js +++ b/options.js @@ -233,7 +233,7 @@ const actionLabels = { }; const speedBindingActions = ["slower", "faster", "fast", "softer", "louder"]; -const requiredShortcutActions = new Set(["display", "slower", "faster"]); +const requiredShortcutActions = new Set(["slower", "faster"]); function formatSpeedBindingDisplay(action, value) { if (!speedBindingActions.includes(action)) { @@ -319,6 +319,70 @@ function refreshAddShortcutSelector() { } } +function refreshSiteRuleAddShortcutSelector(ruleEl) { + if (!ruleEl) return; + var selector = ruleEl.querySelector(".site-add-shortcut-selector"); + if (!selector) return; + + while (selector.options.length > 1) { + 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 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"; + } +} + +function getGlobalBindingSnapshotForSiteShortcut(action) { + var row = document.querySelector( + '#customs .shortcut-row[data-action="' + action + '"]' + ); + if (row) { + var keyInput = row.querySelector(".customKey"); + var binding = normalizeStoredBinding(keyInput && keyInput.vscBinding); + if (binding) { + var valueInput = row.querySelector(".customValue"); + var value = customActionsNoValues.includes(action) + ? 0 + : Number(valueInput && valueInput.value); + return { binding: binding, value: value }; + } + } + var def = tcDefaults.keyBindings.find(function (b) { + return b.action === action; + }); + if (def) { + return { + binding: normalizeStoredBinding(def), + value: def.value + }; + } + return { binding: null, value: undefined }; +} + function ensureDefaultBinding(storage, action, code, value) { if (storage.keyBindings.some((item) => item.action === action)) return; @@ -942,35 +1006,18 @@ function ensureAllDefaultBindings(storage) { }); } -function addSiteRuleShortcut(container, action, binding, value, force) { +function addSiteRuleShortcut(rowsEl, action, binding, value, force) { + if (!rowsEl) return; + var div = document.createElement("div"); div.setAttribute("class", "shortcut-row customs"); div.dataset.action = action; var actionLabel = document.createElement("div"); actionLabel.className = "shortcut-label"; - var actionLabels = { - display: "Show/hide controller", - move: "Move controller", - slower: "Decrease speed", - faster: "Increase speed", - rewind: "Rewind", - advance: "Advance", - reset: "Reset speed", - fast: "Preferred speed", - toggleSubtitleNudge: "Toggle subtitle nudge", - pause: "Play / Pause", - muted: "Mute / Unmute", - louder: "Increase volume", - softer: "Decrease volume", - mark: "Set marker", - jump: "Jump to marker" - }; var actionLabelText = actionLabels[action] || action; if (action === "toggleSubtitleNudge") { - // Check if the site rule is for YouTube. - // We look up the pattern from the site rule element this container belongs to. - var ruleEl = container.closest(".site-rule"); + var ruleEl = rowsEl.closest(".site-rule"); var pattern = ruleEl ? ruleEl.querySelector(".site-pattern").value : ""; if (!pattern.toLowerCase().includes("youtube.com")) { actionLabelText += " (only for YouTube embeds)"; @@ -1014,12 +1061,18 @@ function addSiteRuleShortcut(container, action, binding, value, force) { forceLabel.appendChild(forceCheckbox); forceLabel.appendChild(forceText); + var removeButton = document.createElement("button"); + removeButton.className = "removeParent"; + removeButton.type = "button"; + removeButton.textContent = "\u00d7"; + div.appendChild(actionLabel); div.appendChild(keyInput); div.appendChild(valueInput); div.appendChild(forceLabel); + div.appendChild(removeButton); - container.appendChild(div); + rowsEl.appendChild(div); } function createSiteRule(rule) { @@ -1157,56 +1210,24 @@ function createSiteRule(rule) { rule && Array.isArray(rule.shortcuts) && rule.shortcuts.length > 0 ); ruleEl.querySelector(".override-shortcuts").checked = hasShortcutOverride; - var container = ruleEl.querySelector(".site-shortcuts-container"); + var rowsEl = ruleEl.querySelector(".site-shortcuts-rows"); if (hasShortcutOverride) { rule.shortcuts.forEach((shortcut) => { addSiteRuleShortcut( - container, + rowsEl, shortcut.action, shortcut, shortcut.value, shortcut.force ); }); - } else { - populateDefaultSiteShortcuts(container); } applySiteRuleOverrideState(ruleEl, "override-shortcuts", "site-shortcuts-container"); + refreshSiteRuleAddShortcutSelector(ruleEl); document.getElementById("siteRulesContainer").appendChild(ruleEl); } -function populateDefaultSiteShortcuts(container) { - var bindings = []; - document.querySelectorAll("#customs .shortcut-row").forEach((row) => { - var action = row.dataset.action; - if (!action) return; - - var keyInput = row.querySelector(".customKey"); - var binding = normalizeStoredBinding(keyInput && keyInput.vscBinding); - if (!binding) return; - - var valueInput = row.querySelector(".customValue"); - bindings.push({ - action: action, - code: binding.code, - disabled: binding.disabled === true, - value: customActionsNoValues.includes(action) - ? 0 - : Number(valueInput && valueInput.value), - force: false - }); - }); - - if (bindings.length === 0) { - bindings = tcDefaults.keyBindings.slice(); - } - - bindings.forEach((binding) => { - addSiteRuleShortcut(container, binding.action, binding, binding.value, false); - }); -} - function createControlBarBlock(buttonId) { var def = controllerButtonDefs[buttonId]; if (!def) return null; @@ -1780,8 +1801,13 @@ document.addEventListener("DOMContentLoaded", function () { var removeParentButton = targetEl.closest(".removeParent"); if (removeParentButton) { - removeParentButton.parentNode.remove(); + var removedRow = removeParentButton.parentNode; + var siteRuleForShortcut = removedRow.closest(".site-rule"); + removedRow.remove(); refreshAddShortcutSelector(); + if (siteRuleForShortcut) { + refreshSiteRuleAddShortcutSelector(siteRuleForShortcut); + } return; } var removeSiteRuleButton = targetEl.closest(".remove-site-rule"); @@ -1808,6 +1834,26 @@ document.addEventListener("DOMContentLoaded", function () { } } + if (event.target.classList.contains("site-add-shortcut-selector")) { + var action = event.target.value; + if (!action) return; + var siteRuleRoot = event.target.closest(".site-rule"); + var rows = siteRuleRoot && siteRuleRoot.querySelector(".site-shortcuts-rows"); + if (rows) { + var snap = getGlobalBindingSnapshotForSiteShortcut(action); + addSiteRuleShortcut( + rows, + action, + snap.binding, + snap.value, + false + ); + refreshSiteRuleAddShortcutSelector(siteRuleRoot); + } + event.target.value = ""; + return; + } + // Site rule: show/hide optional override sections var siteOverrideContainers = { "override-placement": "site-placement-container", @@ -1829,6 +1875,9 @@ document.addEventListener("DOMContentLoaded", function () { if (targetBox) { setSiteOverrideContainerState(targetBox, event.target.checked); } + if (ocb === "override-shortcuts") { + refreshSiteRuleAddShortcutSelector(siteRuleRoot); + } return; } } diff --git a/popup.css b/popup.css index adf694e..3fa0c00 100644 --- a/popup.css +++ b/popup.css @@ -210,21 +210,63 @@ button:focus-visible { .donate-split { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(3, 1fr); } -.donate-split button { +.donate-icon-btn { + display: flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 6px 4px; + background: var(--panel); + border: 1px solid var(--border-strong); + color: var(--text); + text-decoration: none; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} + +.donate-icon-btn:hover { + background: #f8f9fb; + border-color: #c5ccd5; +} + +.donate-icon-btn:active { + background: #f1f3f5; +} + +.donate-icon-btn:focus-visible { + outline: 2px solid rgba(17, 24, 39, 0.14); + outline-offset: 2px; + position: relative; + z-index: 1; +} + +.donate-icon-btn svg { + display: block; + flex-shrink: 0; +} + +.donate-icon-btn--kofi img { + display: block; + height: 22px; width: auto; - border-radius: 0; - min-height: 28px; + max-width: 40px; + object-fit: contain; } -.donate-split button:first-child { +.donate-icon-btn:first-child { border-radius: 8px 0 0 8px; - border-right: 0; + border-right-width: 0; } -.donate-split button:last-child { +.donate-icon-btn:nth-child(2) { + border-radius: 0; + border-right-width: 0; +} + +.donate-icon-btn:last-child { border-radius: 0 8px 8px 0; } @@ -266,4 +308,13 @@ button:focus-visible { background: #dfe3e8; border-color: #dfe3e8; } + + .donate-icon-btn:hover { + background: #1f2226; + border-color: #4a515a; + } + + .donate-icon-btn:active { + background: #252a2f; + } } diff --git a/popup.html b/popup.html index cae9e41..0eeba33 100644 --- a/popup.html +++ b/popup.html @@ -35,8 +35,67 @@
diff --git a/popup.js b/popup.js index 58243cc..7f23328 100644 --- a/popup.js +++ b/popup.js @@ -230,14 +230,6 @@ document.addEventListener("DOMContentLoaded", function () { document.querySelector("#donateOptions").classList.remove("hide"); }); - document.querySelector("#donateKofi").addEventListener("click", function () { - window.open("https://ko-fi.com/joshpatra"); - }); - - document.querySelector("#donateGithub").addEventListener("click", function () { - window.open("https://github.com/sponsors/SoPat712"); - }); - document.querySelector("#enable").addEventListener("click", function () { toggleEnabled(true, settingsSavedReloadMessage); }); @@ -279,7 +271,10 @@ document.addEventListener("DOMContentLoaded", function () { var url = context && context.url ? context.url : ""; var siteRule = matchSiteRule(url, storage.siteRules); var siteDisabled = isSiteRuleDisabled(siteRule); - var siteAvailable = storage.enabled !== false && !siteDisabled; + var siteAvailable = siteRuleUtils.isSpeederActiveForSite( + storage.enabled, + siteRule + ); var showBar = storage.showPopupControlBar !== false; if (siteRule && siteRule.showPopupControlBar !== undefined) { diff --git a/shared/site-rules.js b/shared/site-rules.js index 1d327f1..72e17cc 100644 --- a/shared/site-rules.js +++ b/shared/site-rules.js @@ -60,10 +60,28 @@ ); } + /** + * Whether Speeder should run on this URL given global enabled and the matched rule (if any). + * - No rule: follows global (enabled unless explicitly false). + * - Rule with site "off" / disableExtension: always inactive (blacklist). + * - Rule with site "on": active even when global is off (whitelist). + */ + function isSpeederActiveForSite(globalEnabled, siteRule) { + var globalOn = globalEnabled !== false; + if (!siteRule) { + return globalOn; + } + if (isSiteRuleDisabled(siteRule)) { + return false; + } + return true; + } + return { compileSiteRulePattern: compileSiteRulePattern, escapeStringRegExp: escapeStringRegExp, isSiteRuleDisabled: isSiteRuleDisabled, + isSpeederActiveForSite: isSpeederActiveForSite, matchSiteRule: matchSiteRule }; }); diff --git a/tests/inject.test.js b/tests/inject.test.js index 59ba607..357bc9f 100644 --- a/tests/inject.test.js +++ b/tests/inject.test.js @@ -51,6 +51,28 @@ async function bootInject({ sync = {}, local = {} } = {}) { } describe("inject runtime", () => { + it("treats a matching site rule with site enabled as active when global enable is off", async () => { + await bootInject({ + sync: { + enabled: false, + siteRules: [{ pattern: "example.org", enabled: true }] + } + }); + + expect(window.tc.settings.enabled).toBe(false); + window.captureSiteRuleBase(); + window.applySiteRuleOverrides(); + expect(window.tc.activeSiteRule).toEqual( + expect.objectContaining({ pattern: "example.org", enabled: true }) + ); + expect( + window.SpeederShared.siteRules.isSpeederActiveForSite( + window.tc.settings.enabled, + window.tc.activeSiteRule + ) + ).toBe(true); + }); + it("keeps subtitle nudge disabled when the effective setting is off", async () => { await bootInject({ sync: { diff --git a/tests/options.integration.test.js b/tests/options.integration.test.js index 2a57a2f..dc10a39 100644 --- a/tests/options.integration.test.js +++ b/tests/options.integration.test.js @@ -96,6 +96,32 @@ describe("options page", () => { expect(toggle.getAttribute("aria-label")).toBe("Collapse site rule"); }); + it("site rule shortcut override shows no rows by default and adds via selector", async () => { + await setupOptions({ sync: { siteRules: [] } }); + + globalThis.createSiteRule({ pattern: "example.com" }); + const rule = document.getElementById("siteRulesContainer").lastElementChild; + const rows = rule.querySelector(".site-shortcuts-rows"); + const selector = rule.querySelector(".site-add-shortcut-selector"); + + expect(rows.querySelectorAll(".shortcut-row").length).toBe(0); + expect(selector).not.toBeNull(); + expect(selector.disabled).toBe(true); + + rule.querySelector(".override-shortcuts").checked = true; + rule.querySelector(".override-shortcuts").dispatchEvent( + new Event("change", { bubbles: true }) + ); + + expect(selector.disabled).toBe(false); + expect(selector.options.length).toBeGreaterThan(1); + + selector.value = "pause"; + selector.dispatchEvent(new Event("change", { bubbles: true })); + + expect(rows.querySelectorAll('.shortcut-row[data-action="pause"]').length).toBe(1); + }); + it("keeps site override settings visible but disabled until enabled", async () => { await setupOptions({ sync: { siteRules: [] } }); diff --git a/tests/popup.integration.test.js b/tests/popup.integration.test.js index 9c79aa9..d6c80bd 100644 --- a/tests/popup.integration.test.js +++ b/tests/popup.integration.test.js @@ -36,6 +36,22 @@ describe("popup UI", () => { ).toBeGreaterThan(0); }); + it("shows controls when globally disabled but a whitelist site rule matches", async () => { + await setupPopup({ + sync: { + enabled: false, + siteRules: [{ pattern: "example.com", enabled: true }] + } + }); + + expect(document.getElementById("status").classList.contains("hide")).toBe( + true + ); + expect(document.getElementById("popupControlBar").style.display).not.toBe( + "none" + ); + }); + it("shows disabled state for a matching site rule", async () => { await setupPopup({ sync: { diff --git a/tests/shared.test.js b/tests/shared.test.js index c9054f6..0c58acd 100644 --- a/tests/shared.test.js +++ b/tests/shared.test.js @@ -24,6 +24,20 @@ describe("shared helpers", () => { expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true); }); + it("combines global enabled with matched site rules (whitelist / blacklist)", () => { + const allowSite = { pattern: "good.test", enabled: true }; + const blockSite = { pattern: "bad.test", enabled: false }; + + expect(siteRules.isSpeederActiveForSite(true, null)).toBe(true); + expect(siteRules.isSpeederActiveForSite(false, null)).toBe(false); + + expect(siteRules.isSpeederActiveForSite(true, blockSite)).toBe(false); + expect(siteRules.isSpeederActiveForSite(false, blockSite)).toBe(false); + + expect(siteRules.isSpeederActiveForSite(true, allowSite)).toBe(true); + expect(siteRules.isSpeederActiveForSite(false, allowSite)).toBe(true); + }); + it("sanitizes and resolves popup button orders", () => { const controllerButtonDefs = { rewind: {},